released 0.0.1

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.
@@ -0,0 +1,73 @@
1
+ module Released
2
+ class PipelineReader
3
+ def initialize(filename)
4
+ @filename = filename
5
+ end
6
+
7
+ def read
8
+ yaml = transform(YAML.load_file(@filename))
9
+
10
+ goals = []
11
+
12
+ yaml['goals'].each do |goal_yaml|
13
+ name = goal_yaml.keys.first
14
+ config = goal_yaml[name]
15
+ # TODO: what if there are more?
16
+
17
+ goals << Released::Goal.named(name.to_sym).new(config)
18
+ end
19
+
20
+ goals
21
+ end
22
+
23
+ private
24
+
25
+ def transform(obj)
26
+ case obj
27
+ when Hash
28
+ transform_hash(obj)
29
+ when Array
30
+ transform_array(obj)
31
+ when String
32
+ transform_string(obj)
33
+ else
34
+ obj
35
+ end
36
+ end
37
+
38
+ def transform_hash(hash)
39
+ hash.each_with_object({}) do |(key, value), memo|
40
+ memo[key] = transform(value)
41
+ end
42
+ end
43
+
44
+ def transform_array(array)
45
+ array.map do |elem|
46
+ transform(elem)
47
+ end
48
+ end
49
+
50
+ def transform_string(string)
51
+ case string
52
+ when /\Aenv!(.*)/
53
+ ENV.fetch(Regexp.last_match(1))
54
+ when /\Ash!(.*)/
55
+ `#{Regexp.last_match(1)}`
56
+ when /\A-----BEGIN PGP MESSAGE-----/
57
+ decrypt(string)
58
+ else
59
+ string
60
+ end
61
+ end
62
+
63
+ def decrypt(string)
64
+ stdout = ''
65
+ stderr = ''
66
+
67
+ piper = Nanoc::Extra::Piper.new(stdout: stdout, stderr: stderr)
68
+ piper.run(['gpg', '--decrypt', '--no-tty'], string)
69
+
70
+ stdout
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,171 @@
1
+ module Released
2
+ class Runner
3
+ # FIXME: extract UI
4
+
5
+ class TUI
6
+ def initialize(io)
7
+ @io = io
8
+ end
9
+
10
+ def move_up(num)
11
+ @io <<
12
+ if num == 1
13
+ "\e[A"
14
+ else
15
+ "\e[#{num}A"
16
+ end
17
+ end
18
+
19
+ def move_down(num)
20
+ @io <<
21
+ if num == 1
22
+ "\e[B"
23
+ else
24
+ "\e[#{num}B"
25
+ end
26
+ end
27
+
28
+ def move_to_left(col = 1)
29
+ @io <<
30
+ if col == 1
31
+ "\e[G"
32
+ else
33
+ "\e[#{col}G"
34
+ end
35
+ end
36
+
37
+ def clear_to_end_of_line
38
+ @io << "\e[K"
39
+ end
40
+ end
41
+
42
+ def initialize(goals, dry_run: false)
43
+ @goals = goals
44
+ @dry_run = dry_run
45
+
46
+ @tui = TUI.new($stdout)
47
+ @tui_mutex = Mutex.new
48
+ end
49
+
50
+ def run
51
+ assess_all
52
+ try_achieve_all
53
+ end
54
+
55
+ private
56
+
57
+ def print_goals
58
+ @goals.each do |goal|
59
+ left.times { $stdout << '. ' }
60
+
61
+ @tui.move_to_left
62
+ $stdout << goal
63
+ $stdout << ' '
64
+
65
+ @tui.move_to_left(left - 1)
66
+ $stdout << ' '
67
+
68
+ @tui.move_to_left
69
+ @tui.move_down(1)
70
+ end
71
+ end
72
+
73
+ def handle_error(e)
74
+ puts
75
+ puts 'FAILURE!'
76
+ puts '-----'
77
+ puts e.message
78
+ puts
79
+ puts e.backtrace.join("\n")
80
+ puts '-----'
81
+ puts 'Aborting!'
82
+ end
83
+
84
+ def write_state(idx, left, state)
85
+ up = @goals.size - idx
86
+ @tui.move_up(up)
87
+ @tui.move_to_left(left)
88
+ $stdout << state
89
+ @tui.clear_to_end_of_line
90
+ @tui.move_to_left
91
+ @tui.move_down(up)
92
+ end
93
+
94
+ def left
95
+ @_left ||= @goals.map { |g| g.to_s.size }.max + 5
96
+ end
97
+
98
+ def assess_all
99
+ puts 'Assessing goals…'
100
+ print_goals
101
+
102
+ @goals.each.with_index do |_, idx|
103
+ write_state(idx, left, 'waiting')
104
+ end
105
+
106
+ @goals.each.with_index do |goal, idx|
107
+ if goal.assessable?
108
+ write_state(idx, left, 'working…')
109
+
110
+ begin
111
+ goal.assess
112
+ write_state(idx, left, 'ok (succeeded)')
113
+ rescue => e
114
+ write_state(idx, left, 'failed')
115
+ handle_error(e)
116
+ exit 1 # FIXME: eww
117
+ end
118
+ else
119
+ write_state(idx, left, 'ok (skipped)')
120
+ end
121
+ end
122
+
123
+ puts
124
+ end
125
+
126
+ def try_achieve_all
127
+ puts 'Achieving goals…'
128
+ print_goals
129
+
130
+ @goals.each.with_index do |_, idx|
131
+ write_state(idx, left, 'waiting')
132
+ end
133
+
134
+ @goals.each.with_index do |goal, idx|
135
+ if @dry_run
136
+ if goal.achieved?
137
+ write_state(idx, left, 'ok (already achieved)')
138
+ else
139
+ write_state(idx, left, 'pending')
140
+ end
141
+ next
142
+ end
143
+
144
+ if goal.achieved?
145
+ write_state(idx, left, 'ok (already achieved)')
146
+ next
147
+ end
148
+
149
+ begin
150
+ write_state(idx, left, 'working…')
151
+ goal.try_achieve
152
+ rescue => e
153
+ write_state(idx, left, 'errored')
154
+ handle_error(e)
155
+ exit 1 # FIXME: eww
156
+ end
157
+
158
+ if !goal.effectful?
159
+ write_state(idx, left, 'ok (passed)')
160
+ next
161
+ elsif goal.achieved?
162
+ write_state(idx, left, 'ok (newly achieved)')
163
+ next
164
+ else
165
+ write_state(idx, left, 'failed')
166
+ exit 1 # FIXME: eww
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,3 @@
1
+ module Released
2
+ VERSION = '0.0.1'.freeze
3
+ end
data/lib/released.rb ADDED
@@ -0,0 +1,17 @@
1
+ module Released
2
+ end
3
+
4
+ require 'English'
5
+ require 'json'
6
+ require 'net/http'
7
+ require 'shellwords'
8
+ require 'uri'
9
+
10
+ require 'ddplugin'
11
+ require 'nanoc'
12
+
13
+ require_relative 'released/version'
14
+ require_relative 'released/goal'
15
+ require_relative 'released/runner'
16
+ require_relative 'released/pipeline_reader'
17
+ require_relative 'released/goals'
data/released.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ require_relative 'lib/released/version'
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'released'
5
+ s.version = Released::VERSION
6
+ s.homepage = 'https://github.com/ddfreyne/released'
7
+ s.summary = 'extensible release tool'
8
+ s.description = ''
9
+
10
+ s.author = 'Denis Defreyne'
11
+ s.email = 'denis.defreyne@stoneship.org'
12
+ s.license = 'MIT'
13
+
14
+ s.files =
15
+ Dir['[A-Z]*'] +
16
+ Dir['{lib,spec}/**/*'] +
17
+ Dir['*.gemspec']
18
+ s.require_paths = ['lib']
19
+
20
+ s.rdoc_options = ['--main', 'README.md']
21
+
22
+ s.required_ruby_version = '>= 2.3.0'
23
+
24
+ # Dependencies for goals
25
+ # TODO: Move into plugins
26
+ s.add_runtime_dependency('git')
27
+ s.add_runtime_dependency('twitter')
28
+ s.add_runtime_dependency('gems')
29
+ s.add_runtime_dependency('netrc')
30
+ s.add_runtime_dependency('octokit')
31
+
32
+ # TODO: Remove this (copy piper?)
33
+ s.add_runtime_dependency('nanoc', '~> 4.4')
34
+
35
+ s.add_runtime_dependency('ddplugin', '~> 1.0')
36
+
37
+ s.add_development_dependency('bundler', '>= 1.7.10', '< 2.0')
38
+ end
@@ -0,0 +1,64 @@
1
+ describe Released::Goals::GemBuilt do
2
+ subject(:goal) do
3
+ described_class.new(config)
4
+ end
5
+
6
+ let(:config) do
7
+ {
8
+ 'name' => 'donkey',
9
+ 'version' => '0.1',
10
+ }
11
+ end
12
+
13
+ let(:donkey_gemspec) do
14
+ <<~STRING
15
+ Gem::Specification.new do |s|
16
+ s.name = 'donkey'
17
+ s.version = '0.1'
18
+
19
+ s.summary = 'the cutest animal'
20
+ s.author = 'Denis Defreyne'
21
+ s.email = 'denis.defreyne@stoneship.org'
22
+ s.license = 'MIT'
23
+
24
+ s.files = []
25
+ end
26
+ STRING
27
+ end
28
+
29
+ describe '#achieved?' do
30
+ subject { goal.achieved? }
31
+
32
+ context 'file exists' do
33
+ before { File.write('donkey-0.1.gem', 'hello!') }
34
+ it { is_expected.to eql(true) }
35
+ end
36
+
37
+ context 'file does not exist' do
38
+ it { is_expected.to eql(false) }
39
+ end
40
+ end
41
+
42
+ describe '#try_achieve' do
43
+ subject { goal.try_achieve }
44
+
45
+ context 'no gemspec' do
46
+ it 'raises' do
47
+ expect { subject }.to raise_error(/Gemspec file not found/)
48
+ end
49
+ end
50
+
51
+ context 'valid gemspec' do
52
+ before { File.write('donkey.gemspec', donkey_gemspec) }
53
+
54
+ it 'builds the gem' do
55
+ expect { subject }.to change { File.file?('donkey-0.1.gem') }.from(false).to(true)
56
+ end
57
+ end
58
+ end
59
+
60
+ describe '#failure_reason' do
61
+ subject { goal.failure_reason }
62
+ it { is_expected.to eql('expected the file donkey-0.1.gem to exist') }
63
+ end
64
+ end
@@ -0,0 +1,149 @@
1
+ describe Released::Goals::GemPushed, stdio: true do
2
+ subject(:goal) do
3
+ described_class.new(config)
4
+ end
5
+
6
+ let(:config) do
7
+ {
8
+ 'name' => gem_name,
9
+ 'version' => gem_version,
10
+ 'authorization' => authorization,
11
+ 'rubygems_base_url' => rubygems_base_url,
12
+ }
13
+ end
14
+
15
+ let(:gem_name) { 'nanoc' }
16
+ let(:gem_version) { '4.4.2' }
17
+ let(:authorization) { raise 'override me' }
18
+ let(:rubygems_base_url) { 'https://rubygems.org' }
19
+
20
+ let(:correct_authorization) { '83f5b7b9516c4342068cc60063a75de09bdb44d3' }
21
+ let(:incorrect_authorization) { '66666666666666666666666666666666' }
22
+
23
+ before do
24
+ gemspec =
25
+ <<~STRING
26
+ Gem::Specification.new do |s|
27
+ s.name = 'nanoc'
28
+ s.version = '4.4.2'
29
+
30
+ s.summary = 'the best thing ever'
31
+ s.author = 'Denis Defreyne'
32
+ s.email = 'denis.defreyne@stoneship.org'
33
+ s.license = 'MIT'
34
+
35
+ s.files = []
36
+ end
37
+ STRING
38
+
39
+ File.write('donkey.gemspec', gemspec)
40
+ system('gem', 'build', '--silent', 'donkey.gemspec')
41
+ end
42
+
43
+ describe '#assess' do
44
+ subject { goal.assess }
45
+
46
+ context 'incorrect authorization' do
47
+ let(:authorization) { incorrect_authorization }
48
+
49
+ it 'raises' do
50
+ VCR.use_cassette('goals__gem_pushed_spec__assess__incorrect_auth') do
51
+ expect { subject }.to raise_error(
52
+ RuntimeError, 'Authorization failed'
53
+ )
54
+ end
55
+ end
56
+ end
57
+
58
+ context 'correct authorization' do
59
+ let(:authorization) { correct_authorization }
60
+
61
+ context 'response does not include requested gem' do
62
+ let(:gem_name) { 'definitely_not_nanoc' }
63
+
64
+ it 'raises' do
65
+ VCR.use_cassette('goals__gem_pushed_spec__assess__correct_auth_but_gem_not_present') do
66
+ expect { subject }.to raise_error(
67
+ RuntimeError, 'List of owned gems does not include request gem'
68
+ )
69
+ end
70
+ end
71
+ end
72
+
73
+ context 'response includes requested gem' do
74
+ it 'raises' do
75
+ VCR.use_cassette('goals__gem_pushed_spec__assess__correct_auth_and_gem_present') do
76
+ expect { subject }.not_to raise_error
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ describe '#achieved?' do
84
+ subject { goal.achieved? }
85
+
86
+ context 'incorrect authorization' do
87
+ let(:authorization) { incorrect_authorization }
88
+
89
+ it 'raises' do
90
+ VCR.use_cassette('goals__gem_pushed_spec___achieved_q__incorrect_auth') do
91
+ expect { subject }.to raise_error(
92
+ RuntimeError, 'Authorization failed'
93
+ )
94
+ end
95
+ end
96
+ end
97
+
98
+ context 'correct authorization' do
99
+ let(:authorization) { correct_authorization }
100
+
101
+ context 'achieved' do
102
+ it 'is achieved' do
103
+ VCR.use_cassette('goals__gem_pushed_spec___achieved_q__achieved') do
104
+ expect(subject).to be
105
+ end
106
+ end
107
+ end
108
+
109
+ context 'not achieved' do
110
+ it 'is not achieved' do
111
+ VCR.use_cassette('goals__gem_pushed_spec___achieved_q__not_achieved') do
112
+ expect(subject).not_to be
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ describe '#try_achieve' do
120
+ subject { goal.try_achieve }
121
+
122
+ let(:rubygems_repo) do
123
+ Gems::Client.new(
124
+ key: '83f5b7b9516c4342068cc60063a75de09bdb44d3',
125
+ host: 'https://rubygems.org',
126
+ )
127
+ end
128
+
129
+ let(:authorization) { correct_authorization }
130
+
131
+ example do
132
+ VCR.use_cassette('goals__gem_pushed_spec___try_achieve__step_a') do
133
+ expect(rubygems_repo.gems.any? { |g| g['name'] == 'nanoc' && g['version'] == '4.4.2' }).not_to be
134
+ end
135
+
136
+ VCR.use_cassette('goals__gem_pushed_spec___try_achieve__step_b') do
137
+ subject
138
+ end
139
+
140
+ VCR.use_cassette('goals__gem_pushed_spec___try_achieve__step_c') do
141
+ expect(rubygems_repo.gems.any? { |g| g['name'] == 'nanoc' && g['version'] == '4.4.2' }).to be
142
+ end
143
+ end
144
+ end
145
+
146
+ describe '#failure_reason' do
147
+ # TODO
148
+ end
149
+ end
@@ -0,0 +1,74 @@
1
+ describe Released::Goals::GitRefPushed do
2
+ subject(:goal) do
3
+ described_class.new(config)
4
+ end
5
+
6
+ let(:config) do
7
+ {
8
+ 'working_dir' => 'local',
9
+ 'remote' => 'gitlab',
10
+ 'branch' => 'devel',
11
+ }
12
+ end
13
+
14
+ let!(:local) do
15
+ Git.init('local').tap do |g|
16
+ g.config('user.name', 'Testy McTestface')
17
+ g.config('user.email', 'testface@example.com')
18
+
19
+ g.chdir { File.write('hello.txt', 'hi there') }
20
+ g.add('hello.txt')
21
+ g.commit('Add greeting')
22
+ g.branch('devel').checkout
23
+ end
24
+ end
25
+
26
+ let!(:remote) do
27
+ Git.init('remote')
28
+ end
29
+
30
+ before do
31
+ local.add_remote('gitlab', './remote')
32
+ end
33
+
34
+ describe '#achieved?' do
35
+ subject { goal.achieved? }
36
+
37
+ context 'not pushed' do
38
+ it { is_expected.not_to be }
39
+ end
40
+
41
+ context 'pushed, but not right rev' do
42
+ before do
43
+ goal.try_achieve
44
+
45
+ local.chdir { File.write('bye.txt', 'bye now') }
46
+ local.add('bye.txt')
47
+ local.commit('Add farewell')
48
+ local.branch('devel').checkout
49
+ end
50
+
51
+ it { is_expected.not_to be }
52
+ end
53
+
54
+ context 'pushed' do
55
+ before { goal.try_achieve }
56
+ it { is_expected.to be }
57
+ end
58
+ end
59
+
60
+ describe '#try_achieve' do
61
+ subject { goal.try_achieve }
62
+
63
+ example do
64
+ expect(remote.branches['devel']).to be_nil
65
+ subject
66
+ expect(remote.branches['devel'].gcommit.sha).to eql(local.branches['devel'].gcommit.sha)
67
+ end
68
+ end
69
+
70
+ describe '#failure_reason' do
71
+ subject { goal.failure_reason }
72
+ it { is_expected.to eql('HEAD does not exist on gitlab/devel') }
73
+ end
74
+ end
@@ -0,0 +1,49 @@
1
+ describe Released::PipelineReader do
2
+ subject(:pipeline_reader) { described_class.new(filename) }
3
+
4
+ let(:filename) { 'pipeline.yaml' }
5
+
6
+ before do
7
+ ENV['FAVORITE_GEM_AUTHOR'] = 'denis'
8
+ end
9
+
10
+ describe '#transform' do
11
+ subject { pipeline_reader.send(:transform, obj) }
12
+
13
+ context 'with array' do
14
+ let(:obj) { %w(hello env!FAVORITE_GEM_AUTHOR) }
15
+ it { is_expected.to eql(%w(hello denis)) }
16
+ end
17
+
18
+ context 'with hash' do
19
+ let(:obj) { { people: ['env!FAVORITE_GEM_AUTHOR'] } }
20
+ it { is_expected.to eql(people: ['denis']) }
21
+ end
22
+
23
+ context 'with string' do
24
+ context 'normal string' do
25
+ let(:obj) { 'hello' }
26
+ it { is_expected.to eql(obj) }
27
+ end
28
+
29
+ context 'env! string' do
30
+ let(:obj) { 'env!FAVORITE_GEM_AUTHOR' }
31
+ it { is_expected.to eql('denis') }
32
+ end
33
+
34
+ context 'sh! string' do
35
+ let(:obj) { 'sh!echo -n hello' }
36
+ it { is_expected.to eql('hello') }
37
+ end
38
+
39
+ context 'encrypted string' do
40
+ # TODO
41
+ end
42
+ end
43
+
44
+ context 'with anything else' do
45
+ let(:obj) { :donkey }
46
+ it { is_expected.to eql(obj) }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ require 'released'
2
+
3
+ require 'webmock/rspec'
4
+ require 'vcr'
5
+ require 'rspec/its'
6
+
7
+ VCR.configure do |config|
8
+ config.cassette_library_dir = 'fixtures/vcr_cassettes'
9
+ config.hook_into :webmock
10
+ end
11
+
12
+ RSpec.configure do |c|
13
+ c.around(:each, stdio: true) do |example|
14
+ orig_stdout = $stdout
15
+ orig_stderr = $stderr
16
+
17
+ $stdout = StringIO.new
18
+ $stderr = StringIO.new
19
+
20
+ example.run
21
+
22
+ $stdout = orig_stdout
23
+ $stderr = orig_stderr
24
+ end
25
+
26
+ c.around(:each) do |example|
27
+ Dir.mktmpdir('released-specs') do |dir|
28
+ FileUtils.cd(dir) do
29
+ example.run
30
+ end
31
+ end
32
+ end
33
+ end