├── deps.edn ├── .gitignore ├── CHANGELOG.md ├── bb.edn ├── LICENSE ├── README.md ├── API.md └── src └── borkdude ├── gh_release_artifact.clj └── gh_release_artifact └── internal.clj /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {babashka/fs {:mvn/version "0.3.17"} 2 | cheshire/cheshire {:mvn/version "5.11.0"} 3 | org.clj-commons/digest {:mvn/version "1.4.100"} 4 | org.babashka/http-client {:mvn/version "0.1.8"}}} 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | pom.xml.asc 3 | *.jar 4 | *.class 5 | /lib/ 6 | /classes/ 7 | /target/ 8 | /checkouts/ 9 | .lein-deps-sum 10 | .lein-repl-history 11 | .lein-plugins/ 12 | .lein-failures 13 | .nrepl-port 14 | .cpcache/ 15 | .cache 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | [gh-release-artifact](https://github.com/borkdude/gh-release-artifact): Upload artifacts to Github releases idempotently 4 | 5 | ## v0.2.1 6 | 7 | - Fix binary file uploads by upgrading http-client 8 | 9 | ## v0.2.0 10 | 11 | - Replace `babashka.curl` with `babashka.http-client` 12 | - Bump `babashka.fs` 13 | 14 | ## v0.1.0 15 | 16 | Initial release 17 | -------------------------------------------------------------------------------- /bb.edn: -------------------------------------------------------------------------------- 1 | {:deps {current/project {:local/root "."} 2 | io.github.borkdude/quickdoc {:git/url "https://github.com/borkdude/quickdoc" 3 | :git/sha "b290f60fd68380485b613c711d98bea172f2f221"}} 4 | :tasks {quickdoc 5 | {:task (exec 'quickdoc.api/quickdoc) 6 | :exec-args {:git/branch "main" 7 | :github/repo "https://github.com/borkdude/gh-release-artifact"}}}} 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gh-release-artifact 2 | 3 | A babashka and Clojure lib to deploy artifacts to Github releases. 4 | For this to work you need to set an environment variable named 5 | `GITHUB_TOKEN` with your personal access token. You can create 6 | the token on github.com under your profile: 7 | Settings -> Developer settings -> Personal access tokens. 8 | 9 | See [API.md](API.md) for the API. 10 | 11 | Use within babashka as follows. Add to `deps.edn` or `bb.edn`: 12 | 13 | ``` 14 | {:deps {io.github.borkdude/gh-release-artifact {:git/sha "05f8d8659e6805d513c59447ff41dc8497878462"}}} 15 | ``` 16 | 17 | Then in your code: 18 | 19 | ``` clojure 20 | (require '[borkdude.gh-release-artifact :as ghr]) 21 | 22 | (ghr/release-artifact {:org "borkdude" 23 | :repo "test-repo" 24 | :tag "v0.0.15" 25 | :commit "8495a6b872637ea31879c5d56160b8d8e94c9d1c" 26 | :file "README.md" 27 | :sha256 true 28 | :overwrite true}) 29 | ``` 30 | 31 | ## License 32 | 33 | Copyright © 2021 - 2022 Michiel Borkent 34 | 35 | Distributed under the MIT License. See LICENSE. 36 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | - [`borkdude.gh-release-artifact`](#borkdude.gh-release-artifact) 3 | - [`default-mime-types`](#borkdude.gh-release-artifact/default-mime-types) - A map of file extensions to mime-types. 4 | - [`release-artifact`](#borkdude.gh-release-artifact/release-artifact) - Uploads artifact to github release. 5 | 6 | ----- 7 | # borkdude.gh-release-artifact 8 | 9 | 10 | 11 | 12 | 13 | 14 | ## `default-mime-types` [:page_facing_up:](https://github.com/borkdude/gh-release-artifact/blob/main/src/borkdude/gh_release_artifact.clj#L12-L107) 15 | 16 | 17 | A map of file extensions to mime-types. 18 | 19 | ## `release-artifact` [:page_facing_up:](https://github.com/borkdude/gh-release-artifact/blob/main/src/borkdude/gh_release_artifact.clj#L112-L134) 20 | 21 | ``` clojure 22 | 23 | (release-artifact {:keys [overwrite], :or {overwrite false}, :as opts}) 24 | ``` 25 | 26 | 27 | Uploads artifact to github release. Creates (draft) release if there 28 | is no existing release yet. Uses token from `GITHUB_TOKEN` 29 | environment variable for auth. 30 | 31 | Required options: 32 | 33 | * `:org` - Github organization. 34 | * `:repo` - Github repository. 35 | * `:tag` - Tag of release. 36 | * `:file` - The file to be uploaded. 37 | 38 | Optional options: 39 | 40 | * `:commit` - Commit to be associated with release. Defaults to current commit. 41 | * `:sha256` - Upload a `file.sha256` hash file along with `:file`. 42 | * `:overwrite` - Overwrite exiting upload. Defaults to `false`. 43 | * `:draft` - Created draft release. Defaults to `true`. 44 | * `:content-type` - The file's content type. Default to lookup by extension in [`default-mime-types`](#borkdude.gh-release-artifact/default-mime-types). 45 | -------------------------------------------------------------------------------- /src/borkdude/gh_release_artifact.clj: -------------------------------------------------------------------------------- 1 | (ns borkdude.gh-release-artifact 2 | (:require 3 | [borkdude.gh-release-artifact.internal :as ghr])) 4 | 5 | ;; A simple mime type utility from https://github.com/ring-clojure/ring/blob/master/ring-core/src/ring/util/mime_type.clj 6 | (def ^{:doc "A map of file extensions to mime-types."} 7 | default-mime-types 8 | ghr/default-mime-types) 9 | 10 | (defn ^:no-doc overwrite-asset [opts] 11 | (ghr/overwrite-asset opts)) 12 | 13 | (defn release-artifact 14 | "Uploads artifact to github release. Creates (draft) release if there 15 | is no existing release yet. Uses token from `GITHUB_TOKEN` 16 | environment variable for auth. 17 | 18 | Required options: 19 | 20 | * `:org` - Github organization. 21 | * `:repo` - Github repository. 22 | * `:tag` - Tag of release. 23 | * `:file` - The file to be uploaded. 24 | 25 | Optional options: 26 | 27 | * `:commit` - Commit to be associated with release. Defaults to current commit. 28 | * `:sha256` - Upload a `file.sha256` hash file along with `:file`. 29 | * `:overwrite` - Overwrite exiting upload. Defaults to `false`. 30 | * `:draft` - Created draft release. Defaults to `true`. 31 | * `:content-type` - The file's content type. Default to lookup by extension in `default-mime-types`." 32 | [{:keys [overwrite] 33 | :or {overwrite false} 34 | :as opts}] 35 | (overwrite-asset (assoc opts :overwrite overwrite))) 36 | 37 | (comment 38 | (release-artifact {:org "borkdude" 39 | :repo "test-repo" 40 | :tag "v0.0.16" 41 | :commit "8495a6b872637ea31879c5d56160b8d8e94c9d1c" 42 | :file "README.md" 43 | :sha256 true 44 | :overwrite true}) 45 | 46 | (release-artifact {:org "borkdude" 47 | :repo "test-repo" 48 | :tag "v0.0.15" 49 | :commit "8495a6b872637ea31879c5d56160b8d8e94c9d1c" 50 | :file "README.md" 51 | :overwrite false}) 52 | ) 53 | -------------------------------------------------------------------------------- /src/borkdude/gh_release_artifact/internal.clj: -------------------------------------------------------------------------------- 1 | (ns borkdude.gh-release-artifact.internal 2 | {:no-doc true} 3 | (:require 4 | [babashka.http-client :as http] 5 | [babashka.fs :as fs] 6 | [cheshire.core :as cheshire] 7 | [clj-commons.digest :as digest] 8 | [clojure.java.shell :refer [sh]] 9 | [clojure.string :as str])) 10 | 11 | (def token #(System/getenv "GITHUB_TOKEN")) 12 | 13 | (def endpoint "https://api.github.com") 14 | 15 | (defn path [& strs] 16 | (str/join "/" strs)) 17 | 18 | (defn release-endpoint [org repo] 19 | (path endpoint "repos" org repo "releases")) 20 | 21 | (defn with-gh-headers [m] 22 | (update m :headers assoc 23 | "Authorization" (str "token " (token)) 24 | "Accept" "application/vnd.github.v3+json")) 25 | 26 | (defn list-releases [org repo] 27 | (-> (http/get (release-endpoint org repo) 28 | (with-gh-headers {})) 29 | :body 30 | (cheshire/parse-string true))) 31 | 32 | (defn get-draft-release [org repo tag] 33 | (some #(when (= tag (:tag_name %)) %) 34 | ;; always choose oldest release to prevent race condition 35 | (reverse (list-releases org repo)))) 36 | 37 | (defn current-commit [] 38 | (-> (sh "git" "rev-parse" "HEAD") 39 | :out 40 | str/trim)) 41 | 42 | (defn create-release [{:keys [:tag :commit :org :repo :draft 43 | :target-commitish :prerelease] 44 | :or {draft true 45 | target-commitish (or commit 46 | (current-commit))}}] 47 | (-> (http/post (release-endpoint org repo) 48 | (with-gh-headers 49 | {:body 50 | (cheshire/generate-string (cond-> {:tag_name tag 51 | :name tag 52 | :draft draft} 53 | target-commitish 54 | (assoc :target_commitish target-commitish) 55 | prerelease 56 | (assoc :prerelease prerelease)))})) 57 | :body 58 | (cheshire/parse-string true))) 59 | 60 | (defn delete-release [{:keys [:org :repo :id]}] 61 | (http/delete (path (release-endpoint org repo) id) {:throw false})) 62 | 63 | (defn -release-for [{:keys [:org :repo :tag] :as opts}] 64 | (or (get-draft-release org repo tag) 65 | (let [resp (create-release opts) 66 | created-id (:id resp) 67 | release (loop [attempt 0] 68 | (when (< attempt 10) 69 | (Thread/sleep (* attempt 50)) 70 | ;; eventual consistency... 71 | (if-let [dr (get-draft-release org repo tag)] 72 | dr 73 | (recur (inc attempt))))) 74 | release-id (:id release)] 75 | (when-not (= created-id release-id) 76 | ;; in this scenario some other process created a new release just before username 77 | (delete-release (assoc opts :id created-id))) 78 | release))) 79 | 80 | (def release-for (memoize -release-for)) 81 | 82 | (defn list-assets [opts] 83 | (let [release (release-for opts)] 84 | (-> (http/get (:assets_url release) (with-gh-headers {})) 85 | :body 86 | (cheshire/parse-string true)))) 87 | 88 | ;; A simple mime type utility from https://github.com/ring-clojure/ring/blob/master/ring-core/src/ring/util/mime_type.clj 89 | (def ^{:doc "A map of file extensions to mime-types."} 90 | default-mime-types 91 | {"7z" "application/x-7z-compressed" 92 | "aac" "audio/aac" 93 | "ai" "application/postscript" 94 | "appcache" "text/cache-manifest" 95 | "asc" "text/plain" 96 | "atom" "application/atom+xml" 97 | "avi" "video/x-msvideo" 98 | "bin" "application/octet-stream" 99 | "bmp" "image/bmp" 100 | "bz2" "application/x-bzip" 101 | "class" "application/octet-stream" 102 | "cer" "application/pkix-cert" 103 | "crl" "application/pkix-crl" 104 | "crt" "application/x-x509-ca-cert" 105 | "css" "text/css" 106 | "csv" "text/csv" 107 | "deb" "application/x-deb" 108 | "dart" "application/dart" 109 | "dll" "application/octet-stream" 110 | "dmg" "application/octet-stream" 111 | "dms" "application/octet-stream" 112 | "doc" "application/msword" 113 | "dvi" "application/x-dvi" 114 | "edn" "application/edn" 115 | "eot" "application/vnd.ms-fontobject" 116 | "eps" "application/postscript" 117 | "etx" "text/x-setext" 118 | "exe" "application/octet-stream" 119 | "flv" "video/x-flv" 120 | "flac" "audio/flac" 121 | "gif" "image/gif" 122 | "gz" "application/gzip" 123 | "htm" "text/html" 124 | "html" "text/html" 125 | "ico" "image/x-icon" 126 | "iso" "application/x-iso9660-image" 127 | "jar" "application/java-archive" 128 | "jpe" "image/jpeg" 129 | "jpeg" "image/jpeg" 130 | "jpg" "image/jpeg" 131 | "js" "text/javascript" 132 | "json" "application/json" 133 | "lha" "application/octet-stream" 134 | "lzh" "application/octet-stream" 135 | "mov" "video/quicktime" 136 | "m3u8" "application/x-mpegurl" 137 | "m4v" "video/mp4" 138 | "mjs" "text/javascript" 139 | "mp3" "audio/mpeg" 140 | "mp4" "video/mp4" 141 | "mpd" "application/dash+xml" 142 | "mpe" "video/mpeg" 143 | "mpeg" "video/mpeg" 144 | "mpg" "video/mpeg" 145 | "oga" "audio/ogg" 146 | "ogg" "audio/ogg" 147 | "ogv" "video/ogg" 148 | "pbm" "image/x-portable-bitmap" 149 | "pdf" "application/pdf" 150 | "pgm" "image/x-portable-graymap" 151 | "png" "image/png" 152 | "pnm" "image/x-portable-anymap" 153 | "ppm" "image/x-portable-pixmap" 154 | "ppt" "application/vnd.ms-powerpoint" 155 | "ps" "application/postscript" 156 | "qt" "video/quicktime" 157 | "rar" "application/x-rar-compressed" 158 | "ras" "image/x-cmu-raster" 159 | "rb" "text/plain" 160 | "rd" "text/plain" 161 | "rss" "application/rss+xml" 162 | "rtf" "application/rtf" 163 | "sgm" "text/sgml" 164 | "sgml" "text/sgml" 165 | "svg" "image/svg+xml" 166 | "swf" "application/x-shockwave-flash" 167 | "tar" "application/x-tar" 168 | "tif" "image/tiff" 169 | "tiff" "image/tiff" 170 | "ts" "video/mp2t" 171 | "ttf" "font/ttf" 172 | "txt" "text/plain" 173 | "md" "text/plain" 174 | "vsix" "application/vsix" 175 | "webm" "video/webm" 176 | "wmv" "video/x-ms-wmv" 177 | "woff" "font/woff" 178 | "woff2" "font/woff2" 179 | "xbm" "image/x-xbitmap" 180 | "xls" "application/vnd.ms-excel" 181 | "xml" "text/xml" 182 | "xpm" "image/x-xpixmap" 183 | "xwd" "image/x-xwindowdump" 184 | "zip" "application/zip"}) 185 | 186 | (defn overwrite-asset [{:keys [:file :content-type] :as opts}] 187 | (let [release (release-for opts) 188 | upload-url (:upload_url release) 189 | upload-url (str/replace upload-url "{?name,label}" "") 190 | assets (list-assets opts) 191 | file-name (fs/file-name file) 192 | asset (some #(when (= file-name (:name %)) %) assets) 193 | overwrite (get opts :overwrite true) 194 | sha256 (get opts :sha256)] 195 | (when asset 196 | (when overwrite (http/delete (:url asset) (with-gh-headers {:throw false})))) 197 | (when (or (not asset) 198 | ;; in case of asset, overwrite must be true, which it is by default 199 | overwrite) 200 | (let [response (http/post upload-url 201 | {:throw false 202 | :query-params {"name" (fs/file-name file) 203 | "label" (fs/file-name file)} 204 | :headers {"Authorization" (str "token " (token)) 205 | "Content-Type" 206 | (or content-type 207 | (get default-mime-types (fs/extension file)))} 208 | :body (fs/file file)}) 209 | body (-> response :body 210 | (cheshire/parse-string true))] 211 | (prn (:status response)) 212 | (when (and sha256 (= 201 (:status response))) 213 | (let [sha256-fname (str (fs/file-name file) ".sha256") 214 | tmp-dir (fs/create-temp-dir) 215 | hash (digest/sha-256 (fs/file file)) 216 | sha256-file (fs/file tmp-dir sha256-fname) 217 | existing-sha-remote (some #(when (= sha256-fname (:name %)) %) assets)] 218 | (when existing-sha-remote 219 | (http/delete (:url existing-sha-remote) (with-gh-headers {:throw false}))) 220 | (spit sha256-file hash) 221 | (http/post upload-url 222 | {:throw false 223 | :query-params {"name" sha256-fname 224 | "label" sha256-fname} 225 | :headers {"Authorization" (str "token " (token)) 226 | "Content-Type" "text/plain"} 227 | :body (fs/file sha256-file)}))) 228 | body)))) 229 | 230 | (comment 231 | (overwrite-asset {:org "borkdude" 232 | :repo "test-repo" 233 | :tag "v0.0.15" 234 | :commit "8495a6b872637ea31879c5d56160b8d8e94c9d1c" 235 | :file "/Users/borkdude/dev/babashka/logo/babashka-blue-yellow.png" 236 | :sha256 true}) 237 | 238 | (overwrite-asset {:org "borkdude" 239 | :repo "test-repo" 240 | :tag "v0.0.15" 241 | :commit "8495a6b872637ea31879c5d56160b8d8e94c9d1c" 242 | :file "README.md" 243 | :overwrite false}) 244 | ) 245 | 246 | --------------------------------------------------------------------------------