1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
open Bos_setup
module D = struct
let user = "${user}"
let repo = "${repo}"
let dir = Fpath.v "${dir}"
let fetch_head = "${fetch_head}"
end
module Parse = struct
let user_from_remote remote_uri =
let ssh_uri_regexp =
Re.Emacs.compile_pat "git@github\\.com:\\(.+\\)/.+\\(\\.git\\)?"
in
try
let substrings = Re.exec ssh_uri_regexp remote_uri in
Some (Re.Group.get substrings 1)
with Not_found -> None
let archive_upload_url response =
let open Re in
let re =
seq
[ str "\"browser_download_url\":"
; rep space
; char '"'
; group (non_greedy (rep any))
; char '"'
]
in
let compiled = compile re in
let error () = R.error_msgf "Could not extract archive url from:\n %s" response in
match exec_opt compiled response with
| Some groups when Group.test groups 1 -> Ok (Group.get groups 1)
| Some _ | None -> error ()
end
let publish_in_git_branch ~dry_run ~remote ~branch ~name ~version ~docdir ~dir ~yes =
let pp_distrib ppf (name, version) =
Fmt.pf ppf "%a %a" Text.Pp.name name Text.Pp.version version
in
let log_publish_result msg distrib dir =
App_log.success
(fun m -> m "%s %a in directory %a of gh-pages branch" msg pp_distrib distrib Fpath.pp dir)
in
let delete dir =
if not (Fpath.is_current_dir dir) then Sos.delete_dir ~dry_run dir else
let delete acc p = acc >>= fun () -> Sos.delete_path ~dry_run p in
let gitdir = Fpath.v".git" in
let not_git p = not (Fpath.equal p gitdir) in
OS.Dir.contents dir
>>= fun files -> List.fold_left delete (Ok ()) (List.filter not_git files)
in
let git_for_repo r = Cmd.of_list (Cmd.to_list @@ Vcs.cmd r) in
let replace_dir_and_push docdir dir =
let msg = strf "Update %s doc to %s." name version in
Vcs.get ()
>>= fun repo -> Ok (git_for_repo repo)
>>= fun git ->
Sos.run_quiet ~dry_run ~force:(dir <> D.dir) Cmd.(git % "checkout" % branch)
>>= fun () -> delete dir
>>= fun () -> Sos.cp ~dry_run ~rec_:true ~force:true ~src:Fpath.(docdir / ".") ~dst:dir
>>= fun () -> (if dry_run then Ok true else Vcs.is_dirty repo)
>>= function
| false -> Ok false
| true ->
Sos.run ~dry_run Cmd.(git % "add" % p dir)
>>= fun () -> Sos.run_quiet ~dry_run Cmd.(git % "commit" % "-m" % msg)
>>= fun () -> Sos.run_quiet ~dry_run Cmd.(git % "push")
>>= fun () -> Ok true
in
if not (Fpath.is_rooted ~root:Fpath.(v ".") dir)
then
R.error_msgf "%a directory is not rooted in the repository or not relative"
Fpath.pp dir
else
let clonedir = Fpath.(parent (parent (parent docdir)) / "gh-pages") in
Sos.delete_dir ~dry_run ~force:true clonedir
>>= fun () -> Vcs.get ()
>>= fun repo -> Vcs.clone ~dry_run ~force:true ~dir:clonedir repo
>>= fun () -> Sos.relativize ~src:clonedir ~dst:docdir
>>= fun rel_docdir ->
App_log.status (fun l -> l "Updating local %a branch" Text.Pp.commit "gh-pages");
Sos.with_dir ~dry_run clonedir (replace_dir_and_push rel_docdir) dir
>>= fun res -> res
>>= function
| false ->
log_publish_result "No documentation changes for" (name, version) dir;
Ok ()
| true ->
let push_spec = strf "%s:%s" branch branch in
Ok (git_for_repo repo) >>= fun git ->
Prompt.confirm_or_abort ~yes
~question:(fun l -> l "Push new documentation to %a?" Text.Pp.url (remote ^ "#gh-pages"))
>>= fun () ->
App_log.status
(fun l -> l "Pushing new documentation to %a" Text.Pp.url (remote ^ "#gh-pages"));
Sos.run_quiet ~dry_run Cmd.(git % "push" % remote % push_spec)
>>= fun () -> Sos.delete_dir ~dry_run clonedir
>>= fun () ->
log_publish_result "Published documentation for" (name, version) dir;
Ok ()
let publish_doc ~dry_run ~msg:_ ~docdir ~yes p =
(if dry_run then Ok D.(user, repo, dir) else Pkg.doc_user_repo_and_path p)
>>= fun (user, repo, dir) -> Pkg.name p
>>= fun name -> Pkg.version p
>>= fun version ->
let remote = strf "git@@github.com:%s/%s.git" user repo in
let git_for_repo r = Cmd.of_list (Cmd.to_list @@ Vcs.cmd r) in
let force = user <> D.user in
let create_empty_gh_pages git =
let msg = "Initial commit by dune-release." in
let create () =
Sos.run_quiet ~dry_run Cmd.(v "git" % "init")
>>= fun () -> Vcs.get ()
>>= fun repo -> Ok (git_for_repo repo)
>>= fun git -> Sos.run_quiet ~dry_run Cmd.(git % "checkout" % "--orphan" % "gh-pages")
>>= fun () -> Sos.write_file ~dry_run (Fpath.v "README") ""
>>= fun () -> Sos.run_quiet ~dry_run Cmd.(git % "add" % "README")
>>= fun () -> Sos.run_quiet ~dry_run Cmd.(git % "commit" % "README" % "-m" % msg)
in
OS.Dir.with_tmp "gh-pages-%s.tmp" (fun dir () ->
Sos.with_dir ~dry_run dir create () |> R.join >>= fun () ->
let git_fetch = Cmd.(git % "fetch" % Fpath.to_string dir % "gh-pages") in
Sos.run_quiet ~dry_run ~force git_fetch
) () |> R.join
in
Vcs.get ()
>>= fun vcs -> Ok (git_for_repo vcs)
>>= fun git ->
let git_fetch = Cmd.(git % "fetch" % remote % "gh-pages") in
(match Sos.run_quiet ~dry_run ~force git_fetch with
| Ok () -> Ok ()
| Error _ ->
App_log.status
(fun l -> l "Creating new gh-pages branch with inital commit on %s/%s" user repo);
create_empty_gh_pages git)
>>= fun () ->
Sos.run_out ~dry_run ~force Cmd.(git % "rev-parse" % "FETCH_HEAD")
~default:D.fetch_head
OS.Cmd.to_string
>>= fun id ->
Sos.run_quiet ~dry_run ~force Cmd.(git % "branch" % "-f" % "gh-pages" % id)
>>= fun () ->
publish_in_git_branch
~dry_run ~remote ~branch:"gh-pages" ~name ~version ~docdir ~dir ~yes
let github_auth ~dry_run ~user token =
Sos.read_file ~dry_run token >>= fun token ->
Ok (strf "%s:%s" user token)
let create_release_json version msg =
let escape_for_json s =
let len = String.length s in
let max = len - 1 in
let rec escaped_len i l =
if i > max then l else
match String.get s i with
| '\\' | '\"' | '\n' | '\r' | '\t' -> escaped_len (i + 1) (l + 2)
| _ -> escaped_len (i + 1) (l + 1)
in
let escaped_len = escaped_len 0 0 in
if escaped_len = len then s else
let b = Bytes.create escaped_len in
let rec loop i k =
if i > max then Bytes.unsafe_to_string b else
match String.get s i with
| ('\\' | '\"' | '\n' | '\r' | '\t' as c) ->
Bytes.set b k '\\';
let c = match c with
| '\\' -> '\\' | '\"' -> '\"' | '\n' -> 'n' | '\r' -> 'r'
| '\t' -> 't'
| _ -> assert false
in
Bytes.set b (k + 1) c; loop (i + 1) (k + 2)
| c ->
Bytes.set b k c; loop (i + 1) (k + 1)
in
loop 0 0
in
strf "{ \"tag_name\" : \"%s\", \
\"body\" : \"%s\" }" (escape_for_json version) (escape_for_json msg)
let run_with_auth ~dry_run auth curl k =
let auth = strf "-u %s" auth in
Sos.run_io ~dry_run curl (OS.Cmd.in_string auth) k
let curl_create_release ~token ~dry_run curl version msg user repo =
let parse_release_id resp =
let = String.cuts ~sep:"\r\n" resp in
try
let not_slash c = not (Char.equal '/' c) in
let loc = List.find (String.is_prefix ~affix:"Location:") headers in
let id = String.take ~rev:true ~sat:not_slash loc in
match String.to_int id with
| Some id -> Ok id
| None ->
R.error_msgf "Could not parse id from location header %S: %S" loc id
with Not_found ->
R.error_msgf "Could not find release id in response:\n%s."
(String.concat ~sep:"\n" headers)
in
let data = create_release_json version msg in
let uri = strf "https://api.github.com/repos/%s/%s/releases" user repo in
github_auth ~dry_run ~user token >>= fun auth ->
let cmd = Cmd.(curl % "-D" % "-" % "--data" % data % uri) in
run_with_auth ~dry_run ~default:"Location: /0" auth cmd
(OS.Cmd.to_string ~trim:false)
>>= parse_release_id
let curl_upload_archive ~token ~dry_run curl archive user repo release_id =
let uri =
strf "https://uploads.github.com/repos/%s/%s/releases/%d/assets?name=%s"
user repo release_id (Fpath.filename archive)
in
github_auth ~dry_run ~user token >>= fun auth ->
let data = Cmd.(v "--data-binary" % strf "@@%s" (Fpath.to_string archive)) in
let ctype = Cmd.(v "-H" % "Content-Type:application/x-tar") in
let cmd = Cmd.(curl %% ctype %% data % uri) in
run_with_auth ~dry_run ~default:"No response" auth cmd (OS.Cmd.to_string ~trim:false)
let curl_open_pr ~token ~dry_run ~title ~distrib_user ~user ~branch ~body ~opam_repo curl =
let parse_url resp =
let url = Re.(compile @@ seq [
bol;
str {| "html_url":|};
rep space;
char '"';
group (rep (compl [char '"']))
])
in
let alread_exists = Re.(compile @@ str "A pull request already exists") in
try Ok (`Url Re.(Group.get (exec url resp) 1))
with Not_found ->
if Re.execp alread_exists resp then Ok `Already_exists
else R.error_msgf "Could not find html_url id in response:\n%s." resp
in
let base, repo = opam_repo in
let uri = strf "https://api.github.com/repos/%s/%s/pulls" base repo in
let data =
strf {|{"title": %S,"base": "master", "body": %S, "head": "%s:%s"}|}
title body user branch
in
let cmd = Cmd.(curl % "-D" % "-" % "--data" % data % uri) in
github_auth ~dry_run ~user:distrib_user token >>= fun auth ->
let default = {| "html_url": "${pr_url}",|} in
run_with_auth ~dry_run ~default auth cmd (OS.Cmd.to_string ~trim:false)
>>= parse_url
let open_pr ~token ~dry_run ~title ~distrib_user ~user ~branch ~opam_repo body =
OS.Cmd.must_exist Cmd.(v "curl" % "-s" % "-S" % "-K" % "-") >>= fun curl ->
curl_open_pr ~token ~dry_run ~title ~distrib_user ~user ~branch ~body ~opam_repo curl
let dev_repo p =
Pkg.dev_repo p >>= function
| Some r -> Ok r
| None ->
Pkg.opam p >>= fun opam ->
R.error_msgf "The field dev-repo is missing in %a." Fpath.pp opam
let check_tag ~dry_run vcs tag =
if Vcs.tag_exists ~dry_run vcs tag then Ok ()
else
R.error_msgf
"CHANGES.md lists '%s' as the latest release, but no \
corresponding tag has been found in the repository.@.\
Did you forget to call 'dune-release tag' ?"
tag
let assert_tag_exists ~dry_run tag =
Vcs.get () >>= fun repo ->
if Vcs.tag_exists ~dry_run repo tag then Ok ()
else R.error_msgf "%s is not a valid tag" tag
let publish_distrib ~dry_run ~msg ~archive ~yes p =
let git_for_repo r = Cmd.of_list (Cmd.to_list @@ Vcs.cmd r) in
let curl = Cmd.(v "curl" % "-L" % "-s" % "-S" % "-K" % "-") in
(match Pkg.distrib_user_and_repo p with
| Error _ as e -> if dry_run then Ok (D.user, D.repo) else e
| r -> r)
>>= fun (user, repo) -> Pkg.tag p
>>= fun tag -> assert_tag_exists ~dry_run tag
>>= fun () -> OS.Cmd.must_exist curl
>>= fun curl -> Vcs.get ()
>>= fun vcs -> Ok (git_for_repo vcs)
>>= fun git -> Pkg.tag p
>>= fun tag -> check_tag ~dry_run vcs tag
>>= fun () -> dev_repo p
>>= fun upstr ->
Prompt.confirm_or_abort ~yes
~question:(fun l -> l "Push tag %a to %a?" Text.Pp.version tag Text.Pp.url upstr)
>>= fun () ->
App_log.status (fun l -> l "Pushing tag %a to %a" Text.Pp.version tag Text.Pp.url upstr);
Sos.run_quiet ~dry_run Cmd.(git % "push" % "--force" % upstr % tag)
>>= fun () -> Config.token ~dry_run ()
>>= fun token ->
Prompt.confirm_or_abort ~yes
~question:(fun l -> l "Create release %a on %a?" Text.Pp.version tag Text.Pp.url upstr)
>>= fun () ->
App_log.status
(fun l -> l "Creating release %a on %a via github's API" Text.Pp.version tag Text.Pp.url upstr);
curl_create_release ~token ~dry_run curl tag msg user repo
>>= fun id ->
App_log.success (fun l -> l "Succesfully created release with id %d" id);
Prompt.confirm_or_abort ~yes
~question:(fun l -> l "Upload %a as release asset?" Text.Pp.path archive)
>>= fun () ->
App_log.status
(fun l -> l "Uploading %a as a release asset for %a via github's API"
Text.Pp.path
archive
Text.Pp.version
tag);
curl_upload_archive ~token ~dry_run curl archive user repo id
>>= Parse.archive_upload_url