released 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 16ad85ccdafa69e73909d4f175e292aea0b13fd3
4
+ data.tar.gz: 768b7e7e8bc22e4e0d6e406e0b89df6573ea2046
5
+ SHA512:
6
+ metadata.gz: 15ddba448b7ba2e02991a4e2721e35032074bbe648d527e48da881745e62f1f1809b2dfa1f95f2b756d2dde3c1b7c315af88fb5bd96859e97b606f17e2f8637a
7
+ data.tar.gz: f4b2de9c067cc3d0a2ff08522a2c95967d50b4905a55d9efa2ec1d091085c7c121f9d070a8df765b27af71f553d12e0c8527680981f4f0922cae2be9c1210607
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :devel do
6
+ gem 'fuubar'
7
+ gem 'geminabox'
8
+ gem 'json', '~> 2.0'
9
+ gem 'rake'
10
+ gem 'rspec-its'
11
+ gem 'rspec-mocks'
12
+ gem 'rspec'
13
+ gem 'rubocop'
14
+ gem 'vcr'
15
+ gem 'webmock'
16
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,130 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ released (0.0.1)
5
+ ddplugin (~> 1.0)
6
+ gems
7
+ git
8
+ nanoc (~> 4.4)
9
+ netrc
10
+ octokit
11
+ twitter
12
+
13
+ GEM
14
+ remote: https://rubygems.org/
15
+ specs:
16
+ addressable (2.5.0)
17
+ public_suffix (~> 2.0, >= 2.0.2)
18
+ ast (2.3.0)
19
+ builder (3.2.2)
20
+ colored (1.2)
21
+ concurrent-ruby (1.0.2)
22
+ crack (0.4.3)
23
+ safe_yaml (~> 1.0.0)
24
+ cri (2.7.0)
25
+ colored (~> 1.2)
26
+ ddplugin (1.0.0)
27
+ diff-lcs (1.2.5)
28
+ faraday (0.10.0)
29
+ multipart-post (>= 1.2, < 3)
30
+ fuubar (2.2.0)
31
+ rspec-core (~> 3.0)
32
+ ruby-progressbar (~> 1.4)
33
+ geminabox (0.13.4)
34
+ builder
35
+ faraday
36
+ httpclient (>= 2.2.7)
37
+ nesty
38
+ sinatra (>= 1.2.7)
39
+ gems (0.8.3)
40
+ git (1.3.0)
41
+ hamster (3.0.0)
42
+ concurrent-ruby (~> 1.0)
43
+ hashdiff (0.3.1)
44
+ httpclient (2.8.2.4)
45
+ json (2.0.2)
46
+ multi_json (1.12.1)
47
+ multipart-post (2.0.0)
48
+ nanoc (4.4.2)
49
+ cri (~> 2.3)
50
+ hamster (~> 3.0)
51
+ parallel (~> 1.9)
52
+ ref (~> 2.0)
53
+ nesty (1.0.2)
54
+ netrc (0.11.0)
55
+ octokit (4.6.2)
56
+ sawyer (~> 0.8.0, >= 0.5.3)
57
+ parallel (1.10.0)
58
+ parser (2.3.2.0)
59
+ ast (~> 2.2)
60
+ powerpack (0.1.1)
61
+ public_suffix (2.0.4)
62
+ rack (1.6.5)
63
+ rack-protection (1.5.3)
64
+ rack
65
+ rainbow (2.1.0)
66
+ rake (11.3.0)
67
+ ref (2.0.0)
68
+ rspec (3.5.0)
69
+ rspec-core (~> 3.5.0)
70
+ rspec-expectations (~> 3.5.0)
71
+ rspec-mocks (~> 3.5.0)
72
+ rspec-core (3.5.4)
73
+ rspec-support (~> 3.5.0)
74
+ rspec-expectations (3.5.0)
75
+ diff-lcs (>= 1.2.0, < 2.0)
76
+ rspec-support (~> 3.5.0)
77
+ rspec-its (1.2.0)
78
+ rspec-core (>= 3.0.0)
79
+ rspec-expectations (>= 3.0.0)
80
+ rspec-mocks (3.5.0)
81
+ diff-lcs (>= 1.2.0, < 2.0)
82
+ rspec-support (~> 3.5.0)
83
+ rspec-support (3.5.0)
84
+ rubocop (0.45.0)
85
+ parser (>= 2.3.1.1, < 3.0)
86
+ powerpack (~> 0.1)
87
+ rainbow (>= 1.99.1, < 3.0)
88
+ ruby-progressbar (~> 1.7)
89
+ unicode-display_width (~> 1.0, >= 1.0.1)
90
+ ruby-progressbar (1.8.1)
91
+ safe_yaml (1.0.4)
92
+ sawyer (0.8.1)
93
+ addressable (>= 2.3.5, < 2.6)
94
+ faraday (~> 0.8, < 1.0)
95
+ simple_oauth (0.3.1)
96
+ sinatra (1.4.7)
97
+ rack (~> 1.5)
98
+ rack-protection (~> 1.4)
99
+ tilt (>= 1.3, < 3)
100
+ tilt (2.0.5)
101
+ twitter (4.4.2)
102
+ faraday (~> 0.8)
103
+ multi_json (~> 1.3)
104
+ simple_oauth (~> 0.2)
105
+ unicode-display_width (1.1.1)
106
+ vcr (3.0.3)
107
+ webmock (2.1.0)
108
+ addressable (>= 2.3.6)
109
+ crack (>= 0.3.2)
110
+ hashdiff
111
+
112
+ PLATFORMS
113
+ ruby
114
+
115
+ DEPENDENCIES
116
+ bundler (>= 1.7.10, < 2.0)
117
+ fuubar
118
+ geminabox
119
+ json (~> 2.0)
120
+ rake
121
+ released!
122
+ rspec
123
+ rspec-its
124
+ rspec-mocks
125
+ rubocop
126
+ vcr
127
+ webmock
128
+
129
+ BUNDLED WITH
130
+ 1.13.6
data/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # Released
2
+
3
+ Released is a release pipeline tool. It is a possible implementation of [Nanoc RFC 8](https://github.com/nanoc/rfcs/pull/8).
4
+
5
+ ## Status
6
+
7
+ _Released_ is experimental. It lacks features and is not stable. To track development, take a look at the [GitHub projects for Released](https://github.com/ddfreyne/released/projects).
8
+
9
+ ## Example
10
+
11
+ Example pipeline:
12
+
13
+ ```yaml
14
+ goals:
15
+ - shell:
16
+ command: bundle exec rake spec
17
+ - shell:
18
+ command: bundle exec rake rubocop
19
+ - gem_built:
20
+ name: released
21
+ version: env!VERSION
22
+ - gem_pushed:
23
+ name: released
24
+ version: env!VERSION
25
+ authorization: |
26
+ -----BEGIN PGP MESSAGE-----
27
+ Comment: GPGTools - http://gpgtools.org
28
+
29
+ hQIMA2z0x64EwZScAQ/9E4WVht6M/KHgJ0JwGUn77/s7zexsHtc6jUhd0PGHJtTp
30
+ KI/0pnFuKketcoZ2MVGhoKO4zMj7oUgcMk9ajKYe+CtxntWoCVqSKFtuAPD7Sa59
31
+ vcDLMnznNHpF3X6lRoCaRZ9uJYKaxR+HkrKjs8IquX2fr1rmTUy2POQvqZiz8kur
32
+ uYJNT2rV83dVxnnf5Xxi+39XGpHznDYC7Jz0cETTjj69xq8HqLaXG2DMaYGfZQMX
33
+ laL/vABwy63jzpDyp3IUNYEhol9GNJT02kvZLf851LtG7WUif5mH/Yh7jYbDMMbE
34
+ L0bwZ6rwF483QLnrcBQmjVVokRZmgwVZ2aj4FrE9rPPcRP5En4xqnyAoNECqJEIw
35
+ a95f92xOoKhuANo5pFWLkV4sOw5wbY35yY3+URAaXc0xSsfpMi7zDkcMGBI0heZn
36
+ pzFb9rGBOA0k+nLwAS8cIEqVWPmUMUkKaKR2vnJdqMS3we9q8wEwaDY6LyrXZOv9
37
+ 4CgqmGAJAGlODzEzJ3HiW3eP24/8A/s9dpA665/gL497Mt+y2M998hZg6KOVHCVV
38
+ j3JF1h+hY8f/aE/Z1A8rHwh2fSrbBkoJlG+YqqFgstcgVeHPToI+Cqnv4z9MZLxR
39
+ 9USzTsS5aIagiNfKIuugaieGpwphIwy5P3GNRSFpew3yldm47rPAqJi9kgscHrDS
40
+ WwHkjnLwffytKqAeUyQjXdZMvWtd3KN/bdc4n/mSeu+C7MdQzP9SJI9psTlFkpFk
41
+ 4kQZyb0WGIdRH5Rs3KFQ2UaNl+feNi18QFz6vLKamUGuX58OwkvX5TzvnFQ=
42
+ =RMIv
43
+ -----END PGP MESSAGE-----
44
+ ```
45
+
46
+ Example output:
47
+
48
+ ```
49
+ % released nanoc.yaml
50
+ ```
51
+
52
+ ```
53
+ *** Assessing goals…
54
+
55
+ gem pushed (released)… ok
56
+
57
+ *** Achieving goals…
58
+
59
+ shell (bundle exec rake spec)… ok (newly achieved)
60
+ shell (bundle exec rake rubocop)… ok (newly achieved)
61
+ gem built (released)… ok (newly achieved)
62
+ gem pushed (released)… ok (already achieved)
63
+
64
+ Finished! :)
65
+ ```
66
+
67
+ ## Philosophy
68
+
69
+ _Released_’s philosophy is threefold:
70
+
71
+ * **idempotent**: The tool should be able to be run multiple times for the same version, and steps that have already executed should not cause additional effects. For example, if publishing the gem succeeds, but pushing to GitHub fails, the tool should be able to be run again and pushing to GitHub should succeed without failing on the gem push step.
72
+
73
+ * **safe**: If an erroneous condition arises, and continuing could lead to a broken release, the tool should abort. For example, if any of the pre-release verifications fail, the release should not continue.
74
+
75
+ * **resilient**: If a release cannot be made properly due to a dependent service being unavailable, the tool should be able to retry the step, or skip it if the step is deemed to be optional.
76
+
77
+ ## Concepts
78
+
79
+ _Released_ has the following concepts:
80
+
81
+ * pipeline
82
+ * goal
83
+
84
+ The sections below elaborate on these concepts.
85
+
86
+ ### Goal
87
+
88
+ A goal expresses an individual desired end state. For example:
89
+
90
+ * `tests_passing`: the tests are passing
91
+ * `style_checked`: the style checks are passing
92
+ * `pkg_built`: the Debian package is built
93
+ * `pkg_published`: the Debian package is published
94
+ * `tweet_sent`: the tweet about the release is sent
95
+
96
+ Goals are described in a passive voice (e.g. `gem_built`; “the gem has been built”) rather than in the imperative mood (e.g. `build_gem`; “build the gem”). This approach allows expressing what the end state is, rather than how to achieve it. This way, the release process is idempotent: re-running the release process when it has already succeeded before will leave everything as-is, as all goals have already been achieved.
97
+
98
+ When attempting to achieve a goal, _Released_ will first _assess_ the goal in order to determine whether the goal can realistically be obtained given the current circumstances. For example, when the goal is to have a Debian package published, the assessment would fail if no working credentials are available.
99
+
100
+ _Released_ will take the necessary steps to achieve a given goal, but no more than that. If a goal is already achieved, no steps will be taken. It is therefore quite acceptable try to achieve a goal multiple times.
101
+
102
+ TODO: figure out difference between temporary/retriable failures (GitHub is down) and permanent/non-retriable ones (tests are failing)
103
+
104
+ ### Pipeline
105
+
106
+ A pipeline is a sequence of goals. Each goal will be executed in sequence, and a goal will only be executed if no previous goals have failed.
107
+
108
+ ## Pipeline file
109
+
110
+ Strings in `pipeline.yaml` will be replaced according the following rules:
111
+
112
+ * Strings starting with `env!` will be replaced with the value of the environment variable whose name is everything after the exclamation mark. For example, `version: env!VERSION` will become `version: 0.1.2` if the `VERSION` environment variable is set to `0.1.2`.
113
+
114
+ * Strings starting with `sh!` will be replaced with the output of the shell command following the exclamation mark. For example, `version: sh!echo -n 0.1.2` will become `version: 0.1.2`.
115
+
116
+ * Strings starting with `-----BEGIN PGP MESSAGE-----` will be replaced with their content passed through `gpg --decrypt`.
117
+
118
+ ## Defining custom goal types
119
+
120
+ To define a custom goal type, subclass `Released::Goal` and give it an identifier:
121
+
122
+ ```ruby
123
+ class FileExists < Released::Goal
124
+ identifier :file_exists
125
+
126
+ def initialize(config)
127
+ @filename = config.fetch('filename')
128
+ @contents = config.fetch('contents')
129
+ end
130
+
131
+ def try_achieve
132
+ File.write(@filename, @contents)
133
+ end
134
+
135
+ def achieved?
136
+ File.file?(@filename) && File.read(@filename) == @contents
137
+ end
138
+
139
+ def failure_reason
140
+ if !File.file?(@filename)
141
+ "file `#{@filename}` does not exist"
142
+ elsif File.read(@filename) != @contents
143
+ "file `#{@filename}` does not have the expected contents"
144
+ else
145
+ "unknown reason"
146
+ end
147
+ end
148
+ end
149
+ ```
150
+
151
+ Define the following methods:
152
+
153
+ * `initialize(config)` — Initialize the goal with the given configuration. The configuration is a hash whose keys are strings (not symbols).
154
+
155
+ * `try_achieve` — Perform any steps necessary to achieve the goal.
156
+
157
+ * `achieved?` — Return `true` if the goal has been achieved, `false` otherwise. This method should not mutate state.
158
+
159
+ * `failure_reason` — Return a string containing the reason why the goal was not achieved. This method should not mutate state.
160
+
161
+ You might want to implement the following methods as well:
162
+
163
+ * `effectful?` — Return `true` if achieving the goal is effectful, i.e. is expected to cause a state change, `false` otherwise. For example, a `gem_built` goal is effectful, while a `test_passed` goal is not. The default is `true`.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ require 'rubocop/rake_task'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RuboCop::RakeTask.new(:rubocop) do |task|
5
+ task.options = %w(--display-cop-names --format simple)
6
+ task.patterns = ['bin/*', 'lib/**/*.rb', 'spec/**/*.rb']
7
+ end
8
+
9
+ RSpec::Core::RakeTask.new(:spec) do |t|
10
+ t.verbose = false
11
+ end
12
+
13
+ task default: [:spec, :rubocop]
@@ -0,0 +1,38 @@
1
+ module Released
2
+ class Goal
3
+ extend DDPlugin::Plugin
4
+
5
+ # @abstract
6
+ def initialize(_config = {})
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def to_s
11
+ self.class.identifier.to_s
12
+ end
13
+
14
+ def assessable?
15
+ respond_to?(:assess)
16
+ end
17
+
18
+ # @abstract
19
+ def effectful?
20
+ true
21
+ end
22
+
23
+ # @abstract
24
+ def try_achieve
25
+ raise NotImplementedError
26
+ end
27
+
28
+ # @abstract
29
+ def achieved?
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # @abstract
34
+ def failure_reason
35
+ raise NotImplementedError
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,30 @@
1
+ module Released
2
+ module Goals
3
+ class FileExists < Released::Goal
4
+ identifier :file_exists
5
+
6
+ def initialize(config)
7
+ @filename = config.fetch('filename')
8
+ @contents = config.fetch('contents')
9
+ end
10
+
11
+ def try_achieve
12
+ File.write(@filename, @contents)
13
+ end
14
+
15
+ def achieved?
16
+ File.file?(@filename) && File.read(@filename) == @contents
17
+ end
18
+
19
+ def failure_reason
20
+ if !File.file?(@filename)
21
+ "file `#{@filename}` does not exist"
22
+ elsif File.read(@filename) != @contents
23
+ "file `#{@filename}` does not have the expected contents"
24
+ else
25
+ 'unknown reason'
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,46 @@
1
+ module Released
2
+ module Goals
3
+ class GemBuilt < Released::Goal
4
+ identifier :gem_built
5
+
6
+ def initialize(config = {})
7
+ @name = config.fetch('name')
8
+ @version = config.fetch('version')
9
+ end
10
+
11
+ def to_s
12
+ "gem built (#{@name})"
13
+ end
14
+
15
+ def try_achieve
16
+ # TODO: remove
17
+ Dir['*.gem'].each { |f| FileUtils.rm_f(f) }
18
+
19
+ stdout = ''
20
+ stderr = ''
21
+ piper = Nanoc::Extra::Piper.new(stdout: stdout, stderr: stderr)
22
+
23
+ begin
24
+ gemspec_file_path = "#{@name}.gemspec"
25
+ piper.run(['gem', 'build', gemspec_file_path], [])
26
+ rescue
27
+ raise "Failed to build gem: #{stderr}"
28
+ end
29
+ end
30
+
31
+ def achieved?
32
+ File.file?(expected_name)
33
+ end
34
+
35
+ def failure_reason
36
+ "expected the file #{expected_name} to exist"
37
+ end
38
+
39
+ private
40
+
41
+ def expected_name
42
+ @_expected_name ||= @name + '-' + @version + '.gem'
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,60 @@
1
+ require 'gems'
2
+
3
+ module Released
4
+ module Goals
5
+ class GemPushed < Released::Goal
6
+ identifier :gem_pushed
7
+
8
+ BASE_URL = 'https://rubygems.org'.freeze
9
+
10
+ def initialize(config = {})
11
+ @name = config.fetch('name')
12
+ @version = config.fetch('version')
13
+
14
+ @rubygems_repo = Gems::Client.new(
15
+ key: config.fetch('authorization'),
16
+ host: config.fetch('rubygems_base_url', BASE_URL),
17
+ )
18
+ end
19
+
20
+ def to_s
21
+ "gem pushed (#{@name})"
22
+ end
23
+
24
+ def assess
25
+ gems = @rubygems_repo.gems
26
+ if gems =~ /Access Denied/
27
+ raise 'Authorization failed'
28
+ end
29
+
30
+ unless gems.any? { |g| g['name'] == @name }
31
+ raise 'List of owned gems does not include request gem'
32
+ end
33
+ end
34
+
35
+ def try_achieve
36
+ filename = @name + '-' + @version + '.gem'
37
+ unless File.file?(filename)
38
+ raise "no such gem file: #{filename}"
39
+ end
40
+
41
+ File.open(filename, 'r') do |io|
42
+ @rubygems_repo.push(io)
43
+ end
44
+ end
45
+
46
+ def achieved?
47
+ gems = @rubygems_repo.gems
48
+ if gems =~ /Access Denied/
49
+ raise 'Authorization failed'
50
+ end
51
+
52
+ gems.any? { |g| g['name'] == @name && g['version'] == @version }
53
+ end
54
+
55
+ def failure_reason
56
+ "expected list of gems to contain “#{@name}”, version #{@version}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,39 @@
1
+ require 'git'
2
+
3
+ module Released
4
+ module Goals
5
+ class GitRefPushed < Released::Goal
6
+ identifier :git_ref_pushed
7
+
8
+ def initialize(config)
9
+ @working_dir = config.fetch('working_dir')
10
+ @remote = config.fetch('remote')
11
+ @branch = config.fetch('branch')
12
+ end
13
+
14
+ def try_achieve
15
+ g.push(@remote, @branch)
16
+ end
17
+
18
+ def achieved?
19
+ there_branch = g.branches["#{@remote}/#{@branch}"]
20
+ return false if there_branch.nil?
21
+ there = there_branch.gcommit.sha
22
+
23
+ here = g.object('HEAD').sha
24
+
25
+ here == there
26
+ end
27
+
28
+ def failure_reason
29
+ "HEAD does not exist on #{@remote}/#{@branch}"
30
+ end
31
+
32
+ private
33
+
34
+ def g
35
+ @_g ||= Git.open(@working_dir)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,33 @@
1
+ require 'octokit'
2
+
3
+ module Released
4
+ module Goals
5
+ class GitHubReleaseExists < Released::Goal
6
+ identifier :github_release_exists
7
+
8
+ def initialize(config)
9
+ @repository_name = config.fetch('repository_name') # e.g. nanoc/nanoc
10
+ @tag = config.fetch('tag')
11
+ @release_notes = config.fetch('release_notes')
12
+ end
13
+
14
+ def try_achieve
15
+ client = Octokit::Client.new(netrc: true)
16
+ client.create_release(
17
+ @repository_name, @tag,
18
+ body: @release_notes
19
+ )
20
+ end
21
+
22
+ def achieved?
23
+ client = Octokit::Client.new(netrc: true)
24
+ releases = client.releases(@repository_name)
25
+ releases.any? { |r| r.tag_name == @tag }
26
+ end
27
+
28
+ def failure_reason
29
+ "no release exists in repository #{@repository_name} for tag #{@tag}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ module Released
2
+ module Goals
3
+ # TODO: rename
4
+ class Shell < Released::Goal
5
+ identifier :shell
6
+
7
+ def initialize(config = {})
8
+ @command = config.fetch('command')
9
+ end
10
+
11
+ def to_s
12
+ "shell (#{@command})"
13
+ end
14
+
15
+ def effectful?
16
+ false
17
+ end
18
+
19
+ def assess
20
+ sleep 1
21
+ end
22
+
23
+ def try_achieve
24
+ stdout = ''
25
+ stderr = ''
26
+ piper = Nanoc::Extra::Piper.new(stdout: stdout, stderr: stderr)
27
+
28
+ begin
29
+ piper.run(@command, [])
30
+ rescue
31
+ raise "Failed execute command!\n\nstderr:\n#{stderr}\n\nstdout:\n#{stdout}"
32
+ end
33
+ end
34
+
35
+ def achieved?
36
+ false
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ module Released
2
+ module Goals
3
+ end
4
+ end
5
+
6
+ require_relative 'goals/file_exists'
7
+ require_relative 'goals/gem_built'
8
+ require_relative 'goals/gem_pushed'
9
+ require_relative 'goals/git_ref_pushed'
10
+ require_relative 'goals/github_release_exists'
11
+ require_relative 'goals/shell'