ethos-gemkit 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5445351e6ccbd6c2155a603d7c7fcf5d57e3e6a2e275cafa307431c4f4ae63fb
4
+ data.tar.gz: 8b2a74b858376f970c096b2599e5607eb88ca8b78e2ba53f11485d4a2b4d8059
5
+ SHA512:
6
+ metadata.gz: 9cdf0c22ba2d534f270e02904ddc018ebad5448b68bf656ecdd3fceea8f8408b241f108da2f6024a253c67d291d63dbae7e13e3a3fd14c628d8bdcaeab2a5138
7
+ data.tar.gz: d9464b72094dafe244db34ac048e8dda59154784df181db4005c674b217fe946f8d873ecb323617531543b5faa13ffde4f22af1d44b352f40f5576209ed41033
data/CHANGELOG.md ADDED
@@ -0,0 +1,124 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-11-04
9
+
10
+
11
+ ### Added
12
+
13
+ - bootstrap shared gem development contracts
14
+
15
+ - enforce the shared commit message policy
16
+
17
+ - centralize verified gem release orchestration
18
+
19
+ - prepare release PRs and publish after merging
20
+
21
+
22
+
23
+
24
+ ### Changed
25
+
26
+ - exercise standalone core operations
27
+
28
+ - expose shared commit message validation
29
+
30
+ - replace release orchestration with native workflows
31
+
32
+ - remove the redundant bootstrap fixture
33
+
34
+
35
+
36
+
37
+ ### Fixed
38
+
39
+ - lock toolkit development to patched Active Support
40
+
41
+ - disable inherited publishing tasks
42
+
43
+ - complete checksums for frozen hosted installs
44
+
45
+ - declare and isolate framework fixture dependencies
46
+
47
+ - bind package files to the verified tag tree
48
+
49
+ - expose scoped bundle executables to subprocesses
50
+
51
+ - retain confirmed publication evidence for recovery
52
+
53
+ - scope source fetch authentication to GitHub
54
+
55
+ - select the current artifact run explicitly
56
+
57
+ - prevent implicit pushes of unrelated tags
58
+
59
+ - bootstrap Bundler for plain rake commands
60
+
61
+ - keep Bundler's CI cache out of release source
62
+
63
+ - recycle fully merged local release branches
64
+
65
+
66
+
67
+ ## [0.1.0] - 2026-11-04
68
+
69
+
70
+ ### Added
71
+
72
+ - bootstrap shared gem development contracts
73
+
74
+ - enforce the shared commit message policy
75
+
76
+ - centralize verified gem release orchestration
77
+
78
+ - prepare release PRs and publish after merging
79
+
80
+
81
+
82
+
83
+ ### Changed
84
+
85
+ - exercise standalone core operations
86
+
87
+ - expose shared commit message validation
88
+
89
+ - replace release orchestration with native workflows
90
+
91
+ - remove the redundant bootstrap fixture
92
+
93
+
94
+
95
+
96
+ ### Fixed
97
+
98
+ - lock toolkit development to patched Active Support
99
+
100
+ - disable inherited publishing tasks
101
+
102
+ - complete checksums for frozen hosted installs
103
+
104
+ - declare and isolate framework fixture dependencies
105
+
106
+ - bind package files to the verified tag tree
107
+
108
+ - expose scoped bundle executables to subprocesses
109
+
110
+ - retain confirmed publication evidence for recovery
111
+
112
+ - scope source fetch authentication to GitHub
113
+
114
+ - select the current artifact run explicitly
115
+
116
+ - prevent implicit pushes of unrelated tags
117
+
118
+ - bootstrap Bundler for plain rake commands
119
+
120
+
121
+
122
+ # Changelog
123
+
124
+ Changes are generated from Conventional Commits when preparing a release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ethos Link
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 copies
9
+ of the Software, and to permit persons to whom the Software is furnished to do
10
+ 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.
data/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # Ethos Gemkit
2
+
3
+ Shared development defaults for Ethos Link gems: Ruby 4, Standard, optional Rails
4
+ 8 linting, hooks, package checks and one shared release workflow. The toolkit
5
+ belongs in the development/test Gemfile, never a consumer's runtime gemspec.
6
+
7
+ During bootstrap, pin the Git dependency and workflow callers to the same reviewed
8
+ commit; the toolkit has not been published. See the working
9
+ [Cloudflare Tunnel Gemfile](https://github.com/ethos-link/cloudflare-tunnel/blob/feat/ethos-gemkit/Gemfile).
10
+
11
+ A standard gem needs this Rakefile:
12
+
13
+ ```ruby
14
+ require "bundler/setup"
15
+ require "ethos/gemkit/rake"
16
+ ```
17
+
18
+ Use the native configuration files rather than a setup generator:
19
+
20
+ ```yaml
21
+ # .standard.yml
22
+ ruby_version: "4.0"
23
+ plugins:
24
+ - ethos-gemkit
25
+ ```
26
+
27
+ Keep Standard exclusions locally. The plugin detects Rails 8 components from the
28
+ bundle without booting an application; plain Ruby and Active Support-only bundles
29
+ use Ruby rules. SDK Rake tasks reject configurations that omit the shared plugin.
30
+ Existing hooks call `bundle exec ethos-gemkit guard` before commits and
31
+ `bundle exec ethos-gemkit commitlint --message {1}` for commit messages. Install
32
+ hooks with `bundle exec lefthook install`. Direct main/master commits are blocked.
33
+
34
+ No `.ethos-gemkit.yml` is needed for standard public gems. Private gems declare
35
+ `registry: github_packages`; custom checks use `checks: [ci:local]`. Optional
36
+ `gem_name`, `repository` and `version_file` override the one-gemspec, Ethos Link
37
+ repository and one-version-file conventions. Unknown keys and unsafe paths fail.
38
+ The lower-level `ethos/gemkit/tasks` entrypoint retains existing custom test and
39
+ release tasks while a repository is adopting the toolkit.
40
+
41
+ `bundle exec rake verify` runs lint, registered checks, a disposable native gem
42
+ build, and git-cliff validation.
43
+
44
+ ## Releasing a gem
45
+
46
+ **Run `release:prepare` from clean, up-to-date `main`.** It creates the conventional
47
+ release branch and opens a PR. **Merging that release PR starts publication.**
48
+ This is the same process for Gemkit itself and its adopted consumer gems.
49
+ Ordinary feature/fix PRs run verification; only a release PR starts publication.
50
+
51
+ ### One-time prerequisites
52
+
53
+ - Ignore `/.bundle/`, `/vendor/bundle/` and `/pkg/` in `.gitignore`. The shared
54
+ workflows use Ruby setup's native `vendor/bundle` cache; generated files must
55
+ leave the release checkout clean.
56
+ - Install the bundle, Git, git-cliff and GitHub CLI. Configure your Git identity,
57
+ authenticate with `gh auth login`, and install hooks with
58
+ `bundle exec lefthook install`. You need permission to push branches and open PRs.
59
+ - Public gems: configure RubyGems trusted publishing for the gem. Use environment
60
+ `rubygems`, workflow filename `release.yml`, and the consumer owner/repository.
61
+ For a consumer such as Tunnel, set workflow repository owner `ethos-link` and
62
+ workflow repository `ethos-gemkit`. For Gemkit's own publisher, leave those
63
+ optional workflow-repository fields blank because it is the same repository.
64
+ - A new gem also needs a pending trusted publisher. Supply its numeric RubyGems
65
+ owner through the caller's `rubygems-owner` input or the repository/organization
66
+ variable `RUBYGEMS_OWNER` (the Ethos account used by Tunnel is `27209`).
67
+ - Private gems: set the GitHub Packages registry profile and pass a scoped
68
+ `PACKAGES_TOKEN` secret to the release workflow. The two profiles use the same
69
+ native `gem push` with their respective host and credentials.
70
+ - Configure the `rubygems` GitHub environment to allow releases from `main` and
71
+ `v*` tags. Grant the release workflow contents-write and id-token-write
72
+ permissions; repository rules must permit its release tags.
73
+
74
+ The SDK release workflow listens directly for merged release PRs and version-tag
75
+ pushes. Consumer callers must listen for both `pull_request: types: [closed]`
76
+ against `main` and `push: tags: ["v*"]`, and pin the same SDK commit as the Gemfile.
77
+ See the [Tunnel caller](https://github.com/ethos-link/cloudflare-tunnel/blob/feat/ethos-gemkit/.github/workflows/release.yml).
78
+
79
+ ### Prepare and review
80
+
81
+ For a normal patch release:
82
+
83
+ ```sh
84
+ git switch main
85
+ git pull --ff-only
86
+ bundle install
87
+ bundle exec rake release:prepare
88
+ ```
89
+
90
+ The default is `patch`. Select another target explicitly:
91
+
92
+ ```sh
93
+ bundle exec rake 'release:prepare[minor]'
94
+ bundle exec rake 'release:prepare[major]'
95
+ bundle exec rake 'release:prepare[1.2.0]'
96
+ ```
97
+
98
+ For an initial, unpublished version that is already in the gemspec and has no
99
+ tag, an explicit current version is supported. Gemkit's initial release can use
100
+ `bundle exec rake 'release:prepare[0.1.0]'` after pending-publisher setup.
101
+
102
+ The task checks the working tree, canonical origin, current main, installed hooks
103
+ and target tag. It creates `chore/release-vX.Y.Z`, updates the version and
104
+ changelog, runs `bundle lock --local` and the full `rake verify`, then commits the
105
+ three metadata files with hooks enabled, pushes the branch, and opens a
106
+ `chore(release): prepare vX.Y.Z` PR. Main stays unchanged. You can also start on
107
+ that exact pre-created release branch if its HEAD still equals `origin/main`.
108
+ When starting from main, a leftover local release branch is recreated only if
109
+ all its commits are already in main; unmerged work is preserved and stops the task.
110
+
111
+ Review the generated notes, lockfile and version in the PR. Add or adjust upgrade
112
+ notes in a follow-up commit when needed, and wait for CI before merging. The task
113
+ prints the PR URL and makes clear that merging it publishes the release.
114
+
115
+ ### Merge and publish
116
+
117
+ Merging a same-repository `chore/release-vX.Y.Z` PR into main runs the shared
118
+ workflow against its exact merge commit, including squash and rebase merges:
119
+
120
+ 1. Verify the expected version, main ancestry, notes, committed package bytes and
121
+ all registered checks; build one release artifact through Bundler.
122
+ 2. Create and push the annotated `vX.Y.Z` tag without overwriting existing tags
123
+ or following unrelated local tags.
124
+ 3. In an isolated job, verify the artifact's SHA-256 checksums, obtain the proper
125
+ registry credentials, and publish that exact gem.
126
+ 4. In a separate job, create the GitHub release with the same gem and notes.
127
+
128
+ There is no manual tagging step in the normal flow. Build, tag and publication
129
+ continue in the same run because a tag pushed by GitHub's `GITHUB_TOKEN` does not
130
+ start another workflow. Existing manually pushed annotated version tags remain a
131
+ supported trigger and receive full canonical-tag verification.
132
+
133
+ Publish and notes jobs never check out or evaluate consumer code. Public gems
134
+ use short-lived OIDC credentials; private gems use the supplied scoped token.
135
+ No registry fallback exists. Live private publication still needs a private pilot.
136
+
137
+ ### Failures and retries
138
+
139
+ If preparation fails, inspect `git status` and `git diff`. A failed verification
140
+ leaves changes on the local release branch without a release commit or push.
141
+ Fix and finish that ordinary branch, or restore the changes before starting over.
142
+ If the commit/push succeeded but opening the PR failed, use
143
+ `gh pr create --base main --head chore/release-vX.Y.Z --fill` after fixing access.
144
+ There is no journal or automatic reset of your working tree.
145
+
146
+ For CI failures, retry **failed jobs in the original run** within GitHub's 30-day
147
+ retry window. Artifacts are retained for 90 days. A failed tag step can reuse an
148
+ existing annotated tag only when it points to the same verified commit. A failed
149
+ notes job reuses the original artifact without uploading again. Ambiguous uploads,
150
+ already published versions, existing GitHub releases or expired runs/artifacts
151
+ require registry/release reconciliation. Never replace a tag or rebuild a
152
+ published version to repair release notes.
153
+
154
+ Provider references: [reusable RubyGems trusted publishers](https://guides.rubygems.org/trusted-publishing/#using-reusable-workflows),
155
+ [GitHub token-trigger behavior](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow),
156
+ and [failed-job retries](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs).
157
+
158
+ GitLab is a separate optional [workflow](.github/workflows/sync-gitlab.yml).
159
+ Only add its caller when a target exists, passing an SSH `url`, a project write
160
+ deploy key and independently verified known hosts. Without a URL the job skips;
161
+ Cloudflare Tunnel has no caller. It copies current main using a fast-forward
162
+ push, without tags, force or deletions. Provision target visibility no broader
163
+ than the source. Local bare-repository fixtures cover repeat/stale events and
164
+ divergence; live SSH credentials and host keys still require target validation.
165
+
166
+ Develop with `bundle install` and `bundle exec rake verify`. Release and registry
167
+ fixtures live in tests, using real Git and native gem builds with fake network
168
+ publishers. No production rehearsal command or registry client is required.
data/exe/ethos-gemkit ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ethos/gemkit/branch"
5
+ require "ethos/gemkit/commit_message"
6
+ require "optparse"
7
+
8
+ begin
9
+ command = ARGV.shift
10
+ case command
11
+ when "commitlint"
12
+ input = nil
13
+ OptionParser.new do |opts|
14
+ opts.on("--message MESSAGE_OR_FILE") { |value| input = value }
15
+ end.parse!(ARGV)
16
+ abort "Usage: ethos-gemkit commitlint --message MESSAGE_OR_FILE" if input.nil? || !ARGV.empty?
17
+ message = File.file?(input) ? File.read(input) : input
18
+ Ethos::Gemkit::CommitMessage.validate(message)
19
+ puts "Commit message valid"
20
+ when "guard"
21
+ abort "Usage: ethos-gemkit guard" unless ARGV.empty?
22
+ Ethos::Gemkit::Branch.check
23
+ when "version", "--version"
24
+ puts Ethos::Gemkit::VERSION
25
+ else
26
+ abort "Usage: ethos-gemkit guard | commitlint --message MESSAGE_OR_FILE | --version"
27
+ end
28
+ rescue Ethos::Gemkit::Error, OptionParser::ParseError => error
29
+ abort error.message
30
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../gemkit"
5
+
6
+ module Ethos
7
+ module Gemkit
8
+ class Branch
9
+ def self.check(root = Dir.pwd)
10
+ output, error, status = Open3.capture3("git", "branch", "--show-current", chdir: root)
11
+ raise Error, "Cannot inspect Git branch: #{error.strip}" unless status.success?
12
+ branch = output.strip
13
+ if branch.empty? || %w[main master].include?(branch)
14
+ raise Error, "Use a feature branch; direct commits to main/master or detached HEAD are blocked"
15
+ end
16
+ true
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../gemkit"
4
+
5
+ module Ethos
6
+ module Gemkit
7
+ class CommitMessage
8
+ TYPES = %w[feat fix chore refactor docs test perf ci build].freeze
9
+
10
+ def self.validate(message)
11
+ cleaned = message.lines.reject { |line| line.start_with?("#") }.join.strip
12
+ header = cleaned.lines.first.to_s.chomp
13
+ type = header[/\A([a-z]+)(?:\([^()\n]+\))?!?: \S/, 1]
14
+ unless TYPES.include?(type)
15
+ raise Error, "Use a Conventional Commit header with one of: #{TYPES.join(", ")}"
16
+ end
17
+ raise Error, "Commit subject must be at most 72 characters" if header.length > 72
18
+ if cleaned.lines.size > 1 && !cleaned.lines[1].strip.empty?
19
+ raise Error, "Separate the commit subject and body with a blank line"
20
+ end
21
+
22
+ true
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../gemkit"
5
+
6
+ module Ethos
7
+ module Gemkit
8
+ class Configuration
9
+ KEYS = %w[gem_name repository version_file registry checks].freeze
10
+ REGISTRIES = {"rubygems" => "https://rubygems.org", "github_packages" => "https://rubygems.pkg.github.com/ethos-link"}.freeze
11
+
12
+ attr_reader :root, :data
13
+
14
+ def self.load(root = Dir.pwd)
15
+ path = File.join(root, ".ethos-gemkit.yml")
16
+ new(root, File.exist?(path) ? YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: false) : {})
17
+ rescue Psych::Exception, Errno::ENOENT => error
18
+ raise Error, "Cannot load .ethos-gemkit.yml: #{error.message}"
19
+ end
20
+
21
+ def initialize(root, data)
22
+ @root = File.realpath(root)
23
+ raise Error, "Configuration must be a mapping" unless data.is_a?(Hash)
24
+ gemspecs = Dir[File.join(@root, "*.gemspec")]
25
+ raise Error, "Expected exactly one gemspec, or an explicit gem_name" if !data["gem_name"] && !gemspecs.one?
26
+ name = data["gem_name"] || File.basename(gemspecs.first, ".gemspec")
27
+ versions = Dir[File.join(@root, "lib/**/version.rb")].reject { |file| file.include?("templates/") }
28
+ registry = data.fetch("registry", "rubygems")
29
+ registry = {"kind" => registry, "host" => REGISTRIES[registry]} if registry.is_a?(String)
30
+ @data = {"gem_name" => name,
31
+ "repository" => "ethos-link/#{name}", "checks" => ["test"],
32
+ "version_file" => versions.one? ? versions.first.delete_prefix(@root + "/") : "lib/#{name}/version.rb"}.merge(data).merge("registry" => registry)
33
+ validate
34
+ end
35
+
36
+ def name
37
+ data.fetch("gem_name")
38
+ end
39
+
40
+ def checks
41
+ data.fetch("checks")
42
+ end
43
+
44
+ def path(relative)
45
+ unless relative.is_a?(String) && !relative.empty? && !Pathname.new(relative).absolute?
46
+ raise Error, "Expected a relative repository path"
47
+ end
48
+ expanded = File.expand_path(relative, root)
49
+ unless expanded.start_with?(root + File::SEPARATOR)
50
+ raise Error, "Path escapes repository: #{relative}"
51
+ end
52
+ # Existing symlinks must stay inside the checkout too.
53
+ parent = expanded
54
+ parent = File.dirname(parent) until File.exist?(parent) || File.symlink?(parent)
55
+ if File.symlink?(parent) && !File.exist?(parent)
56
+ raise Error, "Dangling symlink is not a repository path: #{relative}"
57
+ end
58
+ unless File.realpath(parent).start_with?(root + File::SEPARATOR) || File.realpath(parent) == root
59
+ raise Error, "Symlink escapes repository: #{relative}"
60
+ end
61
+ expanded
62
+ end
63
+
64
+ private
65
+
66
+ def validate
67
+ require_value(data.is_a?(Hash), "Configuration must be a mapping")
68
+ require_value((data.keys - KEYS).empty?, "Unknown configuration keys: #{data.keys - KEYS}")
69
+ require_value(name.is_a?(String) && name.match?(/\A[a-z0-9][a-z0-9_-]*\z/), "Invalid gem name")
70
+ require_value(data["repository"].is_a?(String) && data["repository"].match?(/\A[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+\z/), "Expected GitHub owner/repository")
71
+ path(data.fetch("version_file"))
72
+ registry = data["registry"]
73
+ require_value(registry.is_a?(Hash) && registry.keys.sort == %w[host kind], "registry requires only kind and host")
74
+ require_value(REGISTRIES.key?(registry["kind"]) && registry["host"] == REGISTRIES[registry["kind"]], "Unsupported registry/host pair")
75
+ require_value(checks.is_a?(Array) && !checks.empty? && checks.all? { |check| check.is_a?(String) && check.match?(/\A[a-zA-Z][a-zA-Z0-9_:]*\z/) }, "checks must name existing Rake tasks")
76
+ require_value(checks.none? { |check| %w[verify default release].include?(check) || check.start_with?("release:") }, "Verification checks must not recurse or publish")
77
+ rescue KeyError => error
78
+ raise Error, "Missing configuration: #{error.key}"
79
+ end
80
+
81
+ def require_value(condition, message)
82
+ raise Error, message unless condition
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/package"
4
+ require "tmpdir"
5
+ require_relative "configuration"
6
+
7
+ module Ethos
8
+ module Gemkit
9
+ class Package
10
+ def initialize(config)
11
+ @config = config
12
+ end
13
+
14
+ def verify
15
+ Dir.chdir(@config.root) do
16
+ spec = Gem::Specification.load(@config.path("#{@config.name}.gemspec"))
17
+ raise Error, "Cannot load gemspec" unless spec
18
+ validate_spec(spec)
19
+ Dir.mktmpdir("ethos-gemkit-package-") do |directory|
20
+ artifact = File.join(directory, "#{spec.full_name}.gem")
21
+ Gem::Package.build(spec, false, false, artifact)
22
+ package = Gem::Package.new(artifact)
23
+ package.verify
24
+ unless package.spec.dependencies.none? { |dependency| dependency.type == :runtime && dependency.name == "ethos-gemkit" }
25
+ raise Error, "ethos-gemkit must not be a runtime dependency"
26
+ end
27
+ end
28
+ end
29
+ true
30
+ end
31
+
32
+ private
33
+
34
+ def validate_spec(spec)
35
+ raise Error, "Configured gem name differs from gemspec" unless spec.name == @config.name
36
+ requirement = spec.required_ruby_version
37
+ unless requirement.satisfied_by?(Gem::Version.new("4.0.0")) && !requirement.satisfied_by?(Gem::Version.new("3.4.99"))
38
+ raise Error, "Gems must require Ruby 4.0 or newer"
39
+ end
40
+ unless spec.metadata["allowed_push_host"] == @config.data.fetch("registry").fetch("host")
41
+ raise Error, "allowed_push_host differs from configured registry"
42
+ end
43
+ raise Error, "Package contains no files" if spec.files.empty?
44
+ spec.files.each do |file|
45
+ @config.path(file)
46
+ if file.match?(%r{(?:\A|/)(?:\.git|\.bundle|node_modules|coverage)(?:/|\z)|(?:\A|/)(?:master\.key|credentials\.yml|\.env(?:\..*)?)\z})
47
+ raise Error, "Unsafe package path: #{file}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "lint_roller"
5
+ require_relative "../gemkit"
6
+
7
+ module Ethos
8
+ module Gemkit
9
+ # Delegate through lint_roller's public interface; never mutate RuboCop internals.
10
+ class Plugin < LintRoller::Plugin
11
+ FRAMEWORKS = %w[rails railties activerecord activemodel actionpack actionview activejob actionmailer activestorage actioncable actionmailbox actiontext].freeze
12
+
13
+ def about
14
+ LintRoller::About.new(name: "ethos-gemkit", version: VERSION,
15
+ homepage: "https://github.com/ethos-link/ethos-gemkit",
16
+ description: "Standard Rails rules when framework components are bundled")
17
+ end
18
+
19
+ def supported?(context)
20
+ true
21
+ end
22
+
23
+ def rules(context)
24
+ specs = Bundler.load.specs.select { |spec| FRAMEWORKS.include?(spec.name) }
25
+ if specs.empty?
26
+ LintRoller::Rules.new(type: :object, config_format: :rubocop, value: {})
27
+ else
28
+ versions = specs.map { |spec| spec.version.segments.first(2) }.uniq
29
+ if versions.size != 1 || versions.first.first != 8
30
+ raise Error, "Rails linting requires consistent Rails 8 components; found #{specs.map { |spec| "#{spec.name}=#{spec.version}" }.join(", ")}"
31
+ end
32
+ require "standard/rails"
33
+ Standard::Rails::Plugin.new(@config.merge("target_rails_version" => versions.first.join(".").to_f)).rules(context)
34
+ end
35
+ rescue LoadError => error
36
+ raise Error, "Install compatible standard-rails support with bundle install: #{error.message}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ unless Rake::Task.task_defined?(:test)
7
+ Rake::TestTask.new(:test) { |task|
8
+ task.libs << "test"
9
+ task.pattern = "test/**/*_test.rb"
10
+ }
11
+ end
12
+ require_relative "release_tasks"
13
+ task default: :verify unless Rake::Task.task_defined?(:default)
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "bundler"
5
+ require_relative "configuration"
6
+ require_relative "branch"
7
+
8
+ module Ethos
9
+ module Gemkit
10
+ class Release
11
+ attr_reader :spec
12
+
13
+ def initialize(config)
14
+ @config = config
15
+ @spec = Gem::Specification.load(config.path("#{config.name}.gemspec"))
16
+ raise Error, "Cannot load gemspec" unless spec
17
+ end
18
+
19
+ # Release metadata uses an ordinary branch and PR; main is never committed.
20
+ def prepare(target = "patch")
21
+ clean
22
+ canonical_origin
23
+ git("fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main")
24
+ unless git("rev-parse", "HEAD") == git("rev-parse", "origin/main")
25
+ raise Error, "Start from clean main at origin/main; fetch and fast-forward before preparing a release"
26
+ end
27
+ %w[pre-commit commit-msg].each do |hook|
28
+ path = File.expand_path(git("rev-parse", "--git-path", "hooks/#{hook}"), @config.root)
29
+ raise Error, "Install hooks with bundle exec lefthook install" unless File.executable?(path)
30
+ end
31
+ file = @config.path(@config.data.fetch("version_file"))
32
+ text = File.read(file)
33
+ current = spec.version.to_s
34
+ parts = current.split(".").map(&:to_i)
35
+ index = %w[major minor patch].index(target)
36
+ if index
37
+ parts[index] += 1
38
+ ((index + 1)..2).each { |position| parts[position] = 0 }
39
+ target = parts.join(".")
40
+ end
41
+ unless target.to_s.match?(/\A\d+\.\d+\.\d+\z/) && Gem::Version.new(target) >= spec.version
42
+ raise Error, "Use major, minor, patch or a X.Y.Z version that has not been tagged"
43
+ end
44
+ literal = %(VERSION = "#{current}")
45
+ raise Error, "Expected a literal #{literal} in version_file" unless text.include?(literal)
46
+ raise Error, "Release tag already exists" unless git("tag", "--list", "v#{target}").empty? && git("ls-remote", "--tags", "origin", "refs/tags/v#{target}").empty?
47
+ branch = "chore/release-v#{target}"
48
+ current_branch = git("branch", "--show-current")
49
+ unless ["main", branch].include?(current_branch)
50
+ raise Error, "Start from main or #{branch} at origin/main"
51
+ end
52
+ run("gh", "auth", "status", "--hostname", "github.com")
53
+ if current_branch == "main"
54
+ unless git("branch", "--list", branch).empty?
55
+ unless git("rev-list", "--count", "origin/main..#{branch}") == "0"
56
+ raise Error, "Existing #{branch} contains unmerged commits; review it before retrying"
57
+ end
58
+ git("branch", "-d", branch)
59
+ end
60
+ git("switch", "-c", branch)
61
+ end
62
+ Branch.check(@config.root)
63
+ File.write(file, text.sub(literal, %(VERSION = "#{target}")))
64
+ run("git-cliff", "--config", "cliff.toml", "--unreleased", "--tag", "v#{target}", "--prepend", "CHANGELOG.md")
65
+ run("bundle", "lock", "--local")
66
+ run("bundle", "exec", "rake", "verify")
67
+ files = [@config.data.fetch("version_file"), "CHANGELOG.md", "Gemfile.lock"]
68
+ unless (git("diff", "--name-only").lines.map(&:strip) - files).empty? && git("ls-files", "--others", "--exclude-standard").empty?
69
+ raise Error, "Verification changed unrelated files; review git diff before committing"
70
+ end
71
+ title = "chore(release): prepare v#{target}"
72
+ body = "Prepare #{spec.name} #{target} for publication.\n\nRecord the version, generated notes and lockfile after verification."
73
+ git("add", "--", *files)
74
+ git("commit", "-m", title, "-m", body)
75
+ clean
76
+ git("push", "--no-follow-tags", "--set-upstream", "origin", branch)
77
+ url = run("gh", "pr", "create", "--repo", @config.data.fetch("repository"),
78
+ "--base", "main", "--head", branch, "--title", title,
79
+ "--body", "#{body}\n\nMerging this PR creates annotated tag v#{target} and publishes the verified gem.").strip
80
+ "#{url}\nMerge this release PR to tag and publish v#{target}."
81
+ end
82
+
83
+ def verify(tag, tagged: true)
84
+ clean
85
+ raise Error, "Tag and gem version differ" unless tag == "v#{spec.version}"
86
+ canonical_origin
87
+ ref = "refs/tags/#{tag}"
88
+ if tagged
89
+ object = git("rev-parse", ref)
90
+ raise Error, "Release tag must be annotated" unless git("cat-file", "-t", ref) == "tag"
91
+ raise Error, "Tag must identify HEAD" unless git("rev-parse", "#{ref}^{}") == git("rev-parse", "HEAD")
92
+ raise Error, "Canonical tag object differs" unless git("ls-remote", "--refs", "origin", ref).split.first == object
93
+ end
94
+ git("fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main")
95
+ git("merge-base", "--is-ancestor", "HEAD", "origin/main")
96
+ spec.files.each do |file|
97
+ unless git("hash-object", "--no-filters", "--", @config.path(file)) == git("rev-parse", "HEAD:#{file}")
98
+ raise Error, "Package file differs from the release commit: #{file}"
99
+ end
100
+ end
101
+ notes = File.read(@config.path("CHANGELOG.md")).split(/^## /).find { |section| section.lines.first.to_s.strip.match?(/\A\[#{Regexp.escape(spec.version.to_s)}\](?: - .*)?\z/) }
102
+ raise Error, "Missing meaningful release notes" unless notes.to_s.lines.any? { |line| line.start_with?("- ") }
103
+ "## #{notes}"
104
+ end
105
+
106
+ private
107
+
108
+ def canonical_origin
109
+ origin = git("config", "--get", "remote.origin.url")
110
+ repo = ENV.fetch("GITHUB_REPOSITORY", @config.data.fetch("repository"))
111
+ unless ["https://github.com/#{repo}.git", "https://github.com/#{repo}", "git@github.com:#{repo}.git", "ssh://git@github.com/#{repo}.git"].include?(origin)
112
+ raise Error, "Release origin must be the canonical GitHub repository"
113
+ end
114
+ end
115
+
116
+ def clean
117
+ status = git("status", "--porcelain")
118
+ raise Error, "Release requires a clean working tree:\n#{status}" unless status.empty?
119
+ end
120
+
121
+ def git(*arguments)
122
+ run("git", *arguments).strip
123
+ end
124
+
125
+ def run(*arguments)
126
+ environment = {"BUNDLE_GEMFILE" => File.join(@config.root, "Gemfile"),
127
+ "GEM_PATH" => [*Gem.path, *Gem.loaded_specs.values.map(&:base_dir)].uniq.join(File::PATH_SEPARATOR)}
128
+ output, error, status = Bundler.with_unbundled_env { Open3.capture3(environment, *arguments, chdir: @config.root) }
129
+ raise Error, "#{arguments.first(3).join(" ")} failed: #{output}#{error}" unless status.success?
130
+ output
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tasks"
4
+ require_relative "release"
5
+
6
+ config = Ethos::Gemkit::Configuration.load
7
+ desc "Create a verified release PR from clean, up-to-date main"
8
+ task "release:prepare", [:target] do |_task, args|
9
+ puts Ethos::Gemkit::Release.new(config).prepare(args[:target] || "patch")
10
+ end
11
+
12
+ desc "Verify release source and build using Bundler's native task"
13
+ task "release:build" => :verify do
14
+ release = Ethos::Gemkit::Release.new(config)
15
+ notes = release.verify(ENV.fetch("RELEASE_TAG"), tagged: ENV["RELEASE_FROM_PR"] != "true")
16
+ Rake::Task[:build].invoke
17
+ File.write(config.path("pkg/release-notes.md"), notes)
18
+ Dir.chdir(config.path("pkg")) do
19
+ sh "sha256sum", "#{release.spec.full_name}.gem", "release-notes.md", out: "SHA256SUMS"
20
+ end
21
+ if ENV["GITHUB_OUTPUT"]
22
+ File.open(ENV.fetch("GITHUB_OUTPUT"), "a") do |file|
23
+ file.puts "gem=#{release.spec.full_name}.gem", "host=#{release.spec.metadata.fetch("allowed_push_host")}", "tag=v#{release.spec.version}"
24
+ end
25
+ end
26
+ end
27
+
28
+ %w[release release:source_control_push release:rubygem_push].each do |name|
29
+ Rake::Task[name].clear if Rake::Task.task_defined?(name)
30
+ task(name) { abort "Publish a reviewed tag through GitHub Actions; local publication is disabled" }
31
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "standard/rake"
4
+ require_relative "configuration"
5
+ require_relative "package"
6
+
7
+ config = Ethos::Gemkit::Configuration.load
8
+ standard = YAML.safe_load_file(config.path(".standard.yml"), aliases: false)
9
+ unless standard.is_a?(Hash) && standard["ruby_version"].to_s == "4.0" && Array(standard["plugins"]).include?("ethos-gemkit")
10
+ raise Ethos::Gemkit::Error, ".standard.yml must target Ruby 4.0 and register ethos-gemkit"
11
+ end
12
+ task lint: :standard
13
+ task "lint:fix" => "standard:fix"
14
+ task "package:verify" do
15
+ Ethos::Gemkit::Package.new(config).verify
16
+ end
17
+
18
+ desc "Run lint, gem checks, package validation and changelog validation"
19
+ task verify: [:lint, *config.checks, "package:verify"] do
20
+ require "tempfile"
21
+ Tempfile.create("gemkit-changelog") { |file| sh "git-cliff", "--config", "cliff.toml", "--unreleased", "--output", file.path }
22
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ethos
4
+ module Gemkit
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gemkit/version"
4
+
5
+ module Ethos
6
+ module Gemkit
7
+ class Error < StandardError; end
8
+ end
9
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ethos/gemkit"
4
+ require_relative "ethos/gemkit/plugin"
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ethos-gemkit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ethos Link
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: standard
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.57'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.57'
26
+ - !ruby/object:Gem::Dependency
27
+ name: standard-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.6'
40
+ - !ruby/object:Gem::Dependency
41
+ name: lint_roller
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: lefthook
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.1'
82
+ description: Common Ruby gem tasks, conditional Rails linting, package validation
83
+ and release verification.
84
+ email:
85
+ - devel@ethos-link.com
86
+ executables:
87
+ - ethos-gemkit
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CHANGELOG.md
92
+ - LICENSE.txt
93
+ - README.md
94
+ - exe/ethos-gemkit
95
+ - lib/ethos-gemkit.rb
96
+ - lib/ethos/gemkit.rb
97
+ - lib/ethos/gemkit/branch.rb
98
+ - lib/ethos/gemkit/commit_message.rb
99
+ - lib/ethos/gemkit/configuration.rb
100
+ - lib/ethos/gemkit/package.rb
101
+ - lib/ethos/gemkit/plugin.rb
102
+ - lib/ethos/gemkit/rake.rb
103
+ - lib/ethos/gemkit/release.rb
104
+ - lib/ethos/gemkit/release_tasks.rb
105
+ - lib/ethos/gemkit/tasks.rb
106
+ - lib/ethos/gemkit/version.rb
107
+ homepage: https://github.com/ethos-link/ethos-gemkit
108
+ licenses:
109
+ - MIT
110
+ metadata:
111
+ allowed_push_host: https://rubygems.org
112
+ source_code_uri: https://github.com/ethos-link/ethos-gemkit
113
+ changelog_uri: https://github.com/ethos-link/ethos-gemkit/blob/main/CHANGELOG.md
114
+ rubygems_mfa_required: 'true'
115
+ default_lint_roller_plugin: Ethos::Gemkit::Plugin
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '4.0'
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ requirements: []
130
+ rubygems_version: 4.0.20
131
+ specification_version: 4
132
+ summary: Shared development and verification tooling for Ethos Link gems
133
+ test_files: []