├── 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 |
--------------------------------------------------------------------------------