releasy 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. data/.yardopts +3 -1
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +114 -41
  4. data/Rakefile +1 -1
  5. data/bin/7z_sfx_LICENSE.txt +56 -0
  6. data/bin/7za.exe +0 -0
  7. data/bin/7za_exe_LICENSE.txt +29 -0
  8. data/lib/releasy.rb +4 -1
  9. data/lib/releasy/builders/builder.rb +14 -6
  10. data/lib/releasy/builders/ocra_builder.rb +4 -2
  11. data/lib/releasy/builders/osx_app.rb +18 -6
  12. data/lib/releasy/builders/source.rb +9 -2
  13. data/lib/releasy/builders/windows_folder.rb +12 -3
  14. data/lib/releasy/builders/windows_installer.rb +2 -3
  15. data/lib/releasy/builders/windows_standalone.rb +13 -3
  16. data/lib/releasy/builders/windows_wrapped.rb +14 -4
  17. data/lib/releasy/cli/install_sfx.rb +1 -1
  18. data/lib/releasy/deployers.rb +1 -1
  19. data/lib/releasy/deployers/deployer.rb +22 -1
  20. data/lib/releasy/deployers/github.rb +31 -124
  21. data/lib/releasy/deployers/local.rb +55 -0
  22. data/lib/releasy/deployers/rsync.rb +59 -0
  23. data/lib/releasy/mixins/has_packagers.rb +1 -1
  24. data/lib/releasy/mixins/utilities.rb +34 -0
  25. data/lib/releasy/packagers/dmg.rb +17 -0
  26. data/lib/releasy/packagers/exe.rb +18 -1
  27. data/lib/releasy/packagers/packager.rb +22 -6
  28. data/lib/releasy/packagers/seven_zip.rb +17 -0
  29. data/lib/releasy/packagers/tar_bzip2.rb +19 -0
  30. data/lib/releasy/packagers/tar_gzip.rb +19 -0
  31. data/lib/releasy/packagers/tar_packager.rb +2 -1
  32. data/lib/releasy/packagers/zip.rb +17 -0
  33. data/lib/releasy/project.rb +45 -4
  34. data/lib/releasy/version.rb +1 -1
  35. data/releasy.gemspec +1 -1
  36. data/test/releasy/builders/data/Main.rb +3 -2
  37. data/test/releasy/builders/helpers/builder_helper.rb +35 -0
  38. data/test/releasy/builders/helpers/ocra_builder_helper.rb +31 -0
  39. data/test/releasy/builders/helpers/windows_builder_helper.rb +25 -0
  40. data/test/releasy/builders/osx_app_test.rb +6 -4
  41. data/test/releasy/builders/source_test.rb +7 -4
  42. data/test/releasy/builders/windows_folder_test.rb +3 -1
  43. data/test/releasy/builders/windows_installer_test.rb +6 -4
  44. data/test/releasy/builders/windows_standalone_test.rb +6 -4
  45. data/test/releasy/builders/windows_wrapped_test.rb +3 -1
  46. data/test/releasy/cli/install_sfx_test.rb +1 -1
  47. data/test/releasy/deployers/github_test.rb +30 -32
  48. data/test/releasy/deployers/local_test.rb +93 -0
  49. data/test/releasy/deployers/rsync_test.rb +55 -0
  50. data/test/releasy/integration/source_test.rb +6 -6
  51. data/test/releasy/mixins/utilities_test.rb +50 -0
  52. data/test/releasy/packagers/packager_test.rb +79 -0
  53. data/test/releasy/packagers_test.rb +12 -6
  54. data/test/releasy/project_test.rb +5 -5
  55. data/test/teststrap.rb +6 -0
  56. data/test/yard_test.rb +1 -1
  57. metadata +40 -28
  58. data/lib/releasy/mixins/exec.rb +0 -14
  59. data/test/releasy/builders/ocra_builder_test.rb +0 -37
  60. data/test/releasy/builders/windows_builder_test.rb +0 -26
@@ -6,6 +6,18 @@ module Releasy
6
6
  module Builders
7
7
  # Builds an OS X application bundle.
8
8
  #
9
+ # @example
10
+ # Releasy::Project.new do
11
+ # name "My App"
12
+ # add_build :osx_app do
13
+ # wrapper "gosu-mac-wrapper-0.7.41.tar.gz" # Required, see {#wrapper=}.
14
+ # url "com.cheese.myapp" # Required
15
+ # icon "media/icon.icns" # Optional
16
+ # exclude_encoding # Optional
17
+ # add_package :dmg # Optional
18
+ # end
19
+ # end
20
+ #
9
21
  # @attr icon [String] Optional filename of icon to show on executable/installer (.icns).
10
22
  class OsxApp < Builder
11
23
  include Mixins::HasGemspecs
@@ -49,13 +61,12 @@ module Releasy
49
61
  raise ConfigError, "#wrapper not set" unless wrapper
50
62
  raise ConfigError, "#wrapper not valid wrapper: #{wrapper}" unless File.basename(wrapper) =~ VALID_GOSU_WRAPPER
51
63
 
52
- directory folder
53
-
54
64
  desc "Build OS X app"
55
65
  task "build:osx:app" => folder
56
66
 
57
67
  file folder => project.files + [wrapper] do
58
- build
68
+ mkdir_p folder, fileutils_options
69
+ build
59
70
  end
60
71
  end
61
72
 
@@ -66,7 +77,7 @@ module Releasy
66
77
  new_app = File.join folder, app_name
67
78
 
68
79
  # Copy the app files.
69
- exec %[7z x -so -bd "#{wrapper}" | 7z x -si -mmt -bd -ttar -o"#{folder}"]
80
+ execute_command %[7z x -so -bd "#{wrapper}" 2>#{null_file} | 7z x -si -mmt -bd -ttar -o"#{folder}"]
70
81
  mv File.join(folder, "RubyGosu App.app"), new_app, fileutils_options
71
82
 
72
83
  ## Copy my source files.
@@ -156,10 +167,11 @@ OSX_EXECUTABLE_FOLDER = File.expand_path("../../..", __FILE__)
156
167
 
157
168
  # Really hacky fudge-fix for something oddly missing in the .app.
158
169
  class Encoding
159
- UTF_7 = UTF_16BE = UTF_16LE = UTF_32BE = UTF_32LE = Encoding.list.first
170
+ BINARY = UTF_7 = UTF_16BE = UTF_16LE = UTF_32BE = UTF_32LE = Encoding.list.first
160
171
  end
161
172
 
162
- load 'application/#{project.executable}'
173
+ Dir.chdir 'application'
174
+ load '#{project.executable}'
163
175
  END_TEXT
164
176
  end
165
177
  end
@@ -3,6 +3,14 @@ require "releasy/builders/builder"
3
3
  module Releasy
4
4
  module Builders
5
5
  # Creates a folder containing the application source.
6
+ #
7
+ # @example
8
+ # Releasy::Project.new do
9
+ # name "My App"
10
+ # add_build :source do
11
+ # add_package :zip # Optional.
12
+ # end
13
+ # end
6
14
  class Source < Builder
7
15
  TYPE = :source
8
16
  Builders.register self
@@ -14,9 +22,8 @@ module Releasy
14
22
  desc "Build source"
15
23
  task "build:source" => folder
16
24
 
17
- directory folder
18
-
19
25
  file folder => project.files do
26
+ mkdir_p folder, fileutils_options
20
27
  copy_files_relative project.files, folder
21
28
  end
22
29
  end
@@ -4,6 +4,16 @@ require 'releasy/windows_wrapper_maker'
4
4
  module Releasy
5
5
  module Builders
6
6
  # Builds a folder containing Ruby + your source + a small Windows executable to run your executable script.
7
+ #
8
+ # @example
9
+ # Releasy::Project.new do
10
+ # name "My App"
11
+ # add_build :windows_folder do
12
+ # icon "media/icon.ico" # Optional
13
+ # exclude_encoding # Optional
14
+ # add_package :zip # Optional
15
+ # end
16
+ # end
7
17
  class WindowsFolder < OcraBuilder
8
18
  TYPE = :windows_folder
9
19
  DEFAULT_FOLDER_SUFFIX = "WIN32"
@@ -13,12 +23,11 @@ module Releasy
13
23
  protected
14
24
  # FOLDER containing EXE, Ruby + source.
15
25
  def generate_tasks
16
- directory project.output_path
17
-
18
26
  file folder => project.files do
27
+ mkdir_p project.output_path, fileutils_options
19
28
  tmp_ocra_executable = "#{folder}.exe"
20
29
 
21
- exec %[#{ocra_command} --output "#{tmp_ocra_executable}" --debug-extract]
30
+ execute_command %[#{ocra_command} --output "#{tmp_ocra_executable}" --debug-extract]
22
31
 
23
32
  # Extract the files from the executable.
24
33
  system tmp_ocra_executable
@@ -21,9 +21,8 @@ module Releasy
21
21
  protected
22
22
  # Regular windows installer, but some users consider them evil.
23
23
  def generate_tasks
24
- directory folder
25
-
26
24
  file folder => project.files do
25
+ mkdir_p folder, fileutils_options
27
26
  create_link_files folder
28
27
  project.exposed_files.each {|file| cp file, folder, fileutils_options }
29
28
 
@@ -51,7 +50,7 @@ module Releasy
51
50
  protected
52
51
  def create_installer(file, options = {})
53
52
  generate_installer_script file, options
54
- exec %[#{ocra_command} --chdir-first --no-lzma --innosetup "#{temp_installer_script}"]
53
+ execute_command %[#{ocra_command} --chdir-first --no-lzma --innosetup "#{temp_installer_script}"]
55
54
  end
56
55
 
57
56
  # Generate innosetup script to build installer.
@@ -5,6 +5,16 @@ module Releasy
5
5
  # Creates a completely standalone Windows executable.
6
6
  #
7
7
  # @note Startup of the executable created by this build takes a couple of seconds longer than running from the other windows builds, as the files are extracted into a temporary directory each time it is run. It is recommended to build with _:windows_folder_ or _:windows_installer_ instead of this, unless you really need to distribute the application as a single file.
8
+ #
9
+ # @example
10
+ # Releasy::Project.new do
11
+ # name "My App"
12
+ # add_build :windows_standalone do
13
+ # icon "media/icon.ico" # Optional
14
+ # exclude_encoding # Optional
15
+ # add_package :zip # Optional
16
+ # end
17
+ # end
8
18
  class WindowsStandalone < OcraBuilder
9
19
  TYPE = :windows_standalone
10
20
  DEFAULT_FOLDER_SUFFIX = "WIN32_EXE"
@@ -14,14 +24,14 @@ module Releasy
14
24
  protected
15
25
  # Self-extracting standalone executable.
16
26
  def generate_tasks
17
- directory folder
18
-
19
27
  file folder => project.files do
28
+ mkdir_p folder, fileutils_options
29
+
20
30
  project.exposed_files.each {|file| cp file, folder, fileutils_options }
21
31
 
22
32
  create_link_files folder
23
33
 
24
- exec %[#{ocra_command} --output "#{folder}/#{executable_name}"]
34
+ execute_command %[#{ocra_command} --output "#{folder}/#{executable_name}"]
25
35
  end
26
36
 
27
37
  desc "Build standalone exe #{project.version} [Ocra]"
@@ -11,6 +11,17 @@ module Releasy
11
11
  # Limitations:
12
12
  # * Does not DLLs loaded from the system, which will have to be included manually if any are required by the application and no universally available in Windows installations.
13
13
  # * Unless a gem is in pure Ruby or available as a pre-compiled binary gem, it won't work!
14
+ #
15
+ # @example
16
+ # Releasy::Project.new do
17
+ # name "My App"
18
+ # add_build :windows_wrapped do
19
+ # wrapper "ruby-1.9.3-p0-i386-mingw32.7z" # Required
20
+ # exclude_encoding # Optional
21
+ # exclude_tcl_tk # Optional
22
+ # add_package :zip # Optional
23
+ # end
24
+ # end
14
25
  class WindowsWrapped < WindowsBuilder
15
26
  include Mixins::HasGemspecs
16
27
 
@@ -48,9 +59,8 @@ module Releasy
48
59
  raise ConfigError, "#wrapper not set" unless wrapper
49
60
  raise ConfigError, "#wrapper not valid wrapper: #{wrapper}" unless File.basename(wrapper) =~ VALID_RUBY_DIST
50
61
 
51
- directory project.output_path
52
-
53
62
  file folder => project.files + [wrapper] do
63
+ mkdir_p project.output_path, fileutils_options
54
64
  build
55
65
  end
56
66
 
@@ -104,7 +114,7 @@ module Releasy
104
114
  protected
105
115
  def copy_ruby_distribution
106
116
  archive_name = File.basename(wrapper).chomp(File.extname(wrapper))
107
- exec %[7z x "#{wrapper}" -o"#{File.dirname folder}"]
117
+ execute_command %[7z x "#{wrapper}" -o"#{File.dirname folder}"]
108
118
  mv File.join(File.dirname(folder), archive_name), folder, fileutils_options
109
119
  rm_r File.join(folder, "share"), fileutils_options
110
120
  rm_r File.join(folder, "include"), fileutils_options if File.exists? File.join(folder, "include")
@@ -138,7 +148,7 @@ module Releasy
138
148
  # If we have a bundle file specified, then gem will _only_ install the version specified by it and not the one we request.
139
149
  bundle_gemfile = ENV['BUNDLE_GEMFILE']
140
150
  ENV['BUNDLE_GEMFILE'] = ''
141
- exec %[gem install "#{spec.name}" --remote --no-rdoc --no-ri --force --ignore-dependencies --platform "#{windows_platform}" --version "#{spec.version}" --install-dir "#{destination}"]
151
+ execute_command %[gem install "#{spec.name}" --remote --no-rdoc --no-ri --force --ignore-dependencies --platform "#{windows_platform}" --version "#{spec.version}" --install-dir "#{destination}"]
142
152
  ENV['BUNDLE_GEMFILE'] = bundle_gemfile
143
153
  binary_gems << spec.name
144
154
  end
@@ -53,7 +53,7 @@ Releasy::Cli.define_command do
53
53
  if ENV["USER"] != "root"
54
54
  command = %[sudo cp "#{sfx_path}" "#{assets_location}"]
55
55
  puts "Copy failed, trying again as super-user:\n#{command}"
56
- exec command
56
+ system command
57
57
  if File.exists? destination_file
58
58
  puts "#{sfx_file} copied to #{assets_location}"
59
59
  else
@@ -7,6 +7,6 @@ module Releasy
7
7
  end
8
8
  end
9
9
 
10
- %w[github].each do |deployer|
10
+ %w[github local rsync].each do |deployer|
11
11
  require "releasy/deployers/#{deployer}"
12
12
  end
@@ -1,5 +1,6 @@
1
1
  require "releasy/mixins/log"
2
2
 
3
+
3
4
  module Releasy
4
5
  module Deployers
5
6
  # @abstract
@@ -7,6 +8,9 @@ module Releasy
7
8
  include Rake::DSL
8
9
  include Mixins::Log
9
10
 
11
+ # Printed out while file is being transferred.
12
+ WORKING_CHARACTER = "."
13
+
10
14
  attr_reader :project
11
15
 
12
16
  def type; self.class::TYPE; end
@@ -20,9 +24,26 @@ module Releasy
20
24
  def generate_tasks(archive_task, folder, extension)
21
25
  desc "#{type} <= #{archive_task.split(":")[0..-2].join(" ")} #{extension}"
22
26
  task "deploy:#{archive_task}:#{type}" => "package:#{archive_task}" do
23
- deploy(folder + extension)
27
+ do_deploy(folder + extension)
24
28
  end
25
29
  end
30
+
31
+ protected
32
+ def do_deploy(file)
33
+ heading "Deploying #{file} (#{(File.size(file).fdiv 1024).ceil}k) to #{self.class.name[/[^:]+$/]}"
34
+
35
+ t = Time.now
36
+
37
+ deploy file
38
+
39
+ minutes, seconds = (Time.now - t).ceil.divmod 60
40
+ hours, minutes = minutes.divmod 60
41
+ duration = "%d:%02d:%02d" % [hours, minutes, seconds]
42
+
43
+ heading "Successfully deployed file in #{duration}"
44
+
45
+ nil
46
+ end
26
47
  end
27
48
  end
28
49
  end
@@ -4,131 +4,45 @@ module Releasy
4
4
  module Deployers
5
5
  # Deploys to a Github project's downloads page.
6
6
  #
7
- # @attr description [String] Description of file (defaults to: "#{project.description")
8
- # @attr login [String] Github user name that has write access to {#repository} (defaults to: `git config github.user` or user name in `git config remote.origin.url`).
9
- # @attr repository [String] Name of Github repository (defaults to: the repository name in `git config remote.origin.url` or _project.underscored_name_).
10
- # @attr token [String] Github token associated with {#login} - a 32-digit hexadecimal string - DO NOT COMMIT A FILE CONTAINING YOUR GITHUB TOKEN (defaults to: `git config github.token`)
7
+ # @example
8
+ # Releasy::Project.new do
9
+ # name "My App"
10
+ # add_build :source
11
+ # add_package :zip
12
+ # add_deploy :github # Should be enough if git is configured correctly.
13
+ # end
14
+ #
15
+ # @attr description [String] (project.description) Description of file.
16
+ # @attr user [String] (`git config github.user` or user name in `git config remote.origin.url`) Github user name that has write access to {#repository}
17
+ # @attr repository [String] (repository name in `git config remote.origin.url` or _project.underscored_name_) Name of Github repository.
18
+ # @attr token [String] (`git config github.token`) Github token associated with {#user} - a 32-digit hexadecimal string - DO NOT COMMIT A FILE CONTAINING YOUR GITHUB TOKEN!
11
19
  class Github < Deployer
12
- # Patch to add an asynchronous version of upload, that also yields every second and takes into account timeout.
13
- module UploaderUploadAsync
14
- def upload_async(info)
15
- unless info[:repos]
16
- raise "required repository name"
17
- end
18
- info[:repos] = @login + '/' + info[:repos] unless info[:repos].include? '/'
19
-
20
- if info[:file]
21
- file = info[:file]
22
- unless File.exist?(file) && File.readable?(file)
23
- raise "file does not exsits or readable"
24
- end
25
- info[:name] ||= File.basename(file)
26
- end
27
- unless info[:file] || info[:data]
28
- raise "required file or data parameter to upload"
29
- end
30
-
31
- unless info[:name]
32
- raise "required name parameter for filename with data parameter"
33
- end
34
-
35
- if info[:replace]
36
- list_files(info[:repos]).each { |obj|
37
- next unless obj[:name] == info[:name]
38
- delete info[:repos], obj[:id]
39
- }
40
- elsif list_files(info[:repos]).any?{|obj| obj[:name] == info[:name]}
41
- raise "file '#{info[:name]}' is already uploaded. please try different name"
42
- end
43
-
44
- info[:content_type] ||= 'application/octet-stream'
45
- stat = HTTPClient.post("https://github.com/#{info[:repos]}/downloads", {
46
- "file_size" => info[:file] ? File.stat(info[:file]).size : info[:data].size,
47
- "content_type" => info[:content_type],
48
- "file_name" => info[:name],
49
- "description" => info[:description] || '',
50
- "login" => @login,
51
- "token" => @token
52
- })
53
-
54
- unless stat.code == 200
55
- raise "Failed to post file info"
56
- end
57
-
58
- upload_info = JSON.parse(stat.content)
59
- if info[:file]
60
- f = File.open(info[:file], 'rb')
61
- else
62
- f = Tempfile.open('net-github-upload')
63
- f << info[:data]
64
- f.flush
65
- end
66
-
67
- client = HTTPClient.new
68
- client.send_timeout = info[:upload_timeout] if info[:upload_timeout]
69
-
70
- res = begin
71
- connection = client.post_async("http://github.s3.amazonaws.com/", [
72
- ['Filename', info[:name]],
73
- ['policy', upload_info['policy']],
74
- ['success_action_status', 201],
75
- ['key', upload_info['path']],
76
- ['AWSAccessKeyId', upload_info['accesskeyid']],
77
- ['Content-Type', upload_info['content_type'] || 'application/octet-stream'],
78
- ['signature', upload_info['signature']],
79
- ['acl', upload_info['acl']],
80
- ['file', f]
81
- ])
82
-
83
- until connection.finished?
84
- yield if block_given?
85
- sleep info[:yield_interval] || 1
86
- end
87
-
88
- connection.pop
89
- ensure
90
- f.close
91
- end
92
-
93
- if res.status == 201
94
- return FasterXmlSimple.xml_in(res.body.read)['PostResponse']['Location']
95
- else
96
- raise 'Failed to upload' + extract_error_message(res.body)
97
- end
98
- end
99
- end
100
-
101
-
102
20
  TYPE = :github
103
21
  # Maximum time to allow an upload to continue. An hour to upload a file isn't unreasonable. Better than the default 2 minutes, which uploads about 4MB for me.
104
22
  UPLOAD_TIMEOUT = 60 * 60
105
23
 
106
24
  Deployers.register self
107
25
 
108
- def repository
109
- @repository || project.underscored_name
110
- end
26
+ def repository; @repository || project.underscored_name; end
111
27
  def repository=(repository)
112
28
  raise TypeError, "repository must be a String, but received #{repository.class}" unless repository.is_a? String
113
29
  @repository = repository
114
30
  end
115
31
 
116
- attr_reader :user
32
+ def user; @user; end
117
33
  def user=(user)
118
34
  raise TypeError, "user must be a String, but received #{user.class}" unless user.is_a? String
119
35
  @user = user
120
36
  end
121
37
 
122
- attr_reader :token
38
+ def token; @token; end
123
39
  def token=(token)
124
40
  raise TypeError, "token must be a String, but received #{token.class}" unless token.is_a? String
125
41
  raise ArgumentError, "token invalid (expected 32-character hex string)" unless token =~ /^[0-9a-f]{32}$/i
126
42
  @token = token
127
43
  end
128
44
 
129
- def description
130
- @description || project.description
131
- end
45
+ def description; @description || project.description; end
132
46
  def description=(description)
133
47
  raise TypeError, "description must be a String, but received #{description.class}" unless description.is_a? String
134
48
  @description = description
@@ -164,44 +78,37 @@ module Releasy
164
78
  # @param key [String] Name of setting in git config.
165
79
  # @return [String, nil] Value of setting, else nil if it isn't defined.
166
80
  def from_config(key)
167
- `git config #{key}`.chomp rescue nil
81
+ Kernel.`("git config #{key}").chomp rescue nil
168
82
  end
169
83
 
170
84
  protected
171
85
  # @param file [String] Path to file to deploy.
172
- # @return [String] A link to download the file.
173
- # @raise SystemError If file fails to upload.
86
+ # @return [nil]
174
87
  def deploy(file)
175
88
  raise ConfigError, "#user must be set manually if it is not configured on the system" unless user
176
89
  raise ConfigError, "#token must be set manually if it is not configured on the system" unless token
177
90
 
91
+ info %[Uploading to: https://github.com/downloads/#{user}/#{repository}/#{File.basename(file)}]
92
+
178
93
  # Hold off requiring this unless needed, so it doesn't slow down creating tasks.
179
- if require 'net/github-upload'
180
- Net::GitHub::Upload.send :include, UploaderUploadAsync
181
- end
94
+ require 'net/github-upload'
182
95
 
183
96
  uploader = Net::GitHub::Upload.new(:login => user, :token => token)
184
97
 
185
- heading "Deploying #{file} (#{(File.size(file).fdiv 1024).ceil}k) to Github"
186
-
187
- t = Time.now
188
-
189
98
  begin
190
- uploader.upload_async :repos => repository, :file => file, :description => description, :replace => @force_replace, :upload_timeout => UPLOAD_TIMEOUT do
191
- print '.'
99
+ uploader.upload :repos => repository, :file => file, :description => description, :replace => @force_replace, :upload_timeout => UPLOAD_TIMEOUT do
100
+ print WORKING_CHARACTER unless log_level == :silent
101
+ end
102
+ info WORKING_CHARACTER
103
+ rescue RuntimeError => ex
104
+ if ex.message =~ /file .* is already uploaded/i
105
+ warn "Skipping '#{File.basename file}' as it is already uploaded. Use #replace! to force uploading"
106
+ else
107
+ raise ex
192
108
  end
193
- puts '.'
194
- rescue => ex
195
- # Probably failed to overwrite an existing file.
196
- error "Error uploading file #{file}: #{ex.message}"
197
- exit 1 # This is bad. Lets just die, die, die at this point.
198
109
  end
199
110
 
200
- link = "https://github.com/downloads/#{user}/#{repository}/#{File.basename(file)}"
201
- time = "%d:%02d" % (Time.now - t).ceil.divmod(60)
202
- heading %[Successfully uploaded to "#{link}" in #{time}]
203
-
204
- link
111
+ nil
205
112
  end
206
113
  end
207
114
  end