ruby-gem-release 0.4.0 → 0.5.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +23 -18
  4. data/lib/ruby-gem-release/changelog/base.rb +24 -0
  5. data/lib/ruby-gem-release/changelog/generated.rb +48 -0
  6. data/lib/ruby-gem-release/changelog/intridea.rb +16 -2
  7. data/lib/ruby-gem-release/changelog/keep_a_changelog.rb +14 -3
  8. data/lib/ruby-gem-release/changelog.rb +1 -0
  9. data/lib/ruby-gem-release/destination/ruby_gems.rb +69 -0
  10. data/lib/ruby-gem-release/destination.rb +19 -0
  11. data/lib/ruby-gem-release/execution_context/github/run.rb +31 -0
  12. data/lib/ruby-gem-release/execution_context/github/step.rb +28 -0
  13. data/lib/ruby-gem-release/execution_context/github.rb +33 -0
  14. data/lib/ruby-gem-release/execution_context/step.rb +16 -0
  15. data/lib/ruby-gem-release/execution_context.rb +32 -0
  16. data/lib/ruby-gem-release/execution_provider/github.rb +81 -0
  17. data/lib/ruby-gem-release/execution_provider.rb +65 -0
  18. data/lib/ruby-gem-release/github_api.rb +35 -0
  19. data/lib/ruby-gem-release/hosting/github_release/existing.rb +64 -0
  20. data/lib/ruby-gem-release/hosting/github_release.rb +52 -6
  21. data/lib/ruby-gem-release/package/ruby_gem.rb +22 -37
  22. data/lib/ruby-gem-release/release.rb +15 -9
  23. data/lib/ruby-gem-release/release_failure.rb +36 -0
  24. data/lib/ruby-gem-release/semantic_release/conventional_commits.rb +102 -0
  25. data/lib/ruby-gem-release/semantic_release/decision.rb +111 -0
  26. data/lib/ruby-gem-release/semantic_release/git_history.rb +43 -0
  27. data/lib/ruby-gem-release/semantic_release/release_notes.rb +40 -0
  28. data/lib/ruby-gem-release/semantic_release/version_selector.rb +48 -0
  29. data/lib/ruby-gem-release/semantic_release.rb +28 -0
  30. data/lib/ruby-gem-release/source_control/git/preparation.rb +1 -1
  31. data/lib/ruby-gem-release/source_control/git.rb +117 -21
  32. data/lib/ruby-gem-release/tasks.rb +190 -32
  33. data/lib/ruby-gem-release/version/ruby_constant.rb +7 -3
  34. data/lib/ruby-gem-release/version.rb +1 -1
  35. data/lib/ruby-gem-release/workflow/environment.rb +110 -0
  36. data/lib/ruby-gem-release/workflow/failure.rb +47 -0
  37. data/lib/ruby-gem-release/workflow/ruby_gem.rb +57 -13
  38. data/lib/ruby-gem-release/workflow/ruby_gems_credentials.rb +13 -26
  39. data/lib/ruby-gem-release/workflow/semantic_ruby_gem.rb +64 -0
  40. data/lib/ruby-gem-release/workflow/state.rb +94 -0
  41. metadata +25 -3
  42. data/lib/ruby-gem-release/workflow/context.rb +0 -96
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1babfa3cbf929529a6e8d82e20d3caff5a890c65169dd9db841421e1b2e64057
4
- data.tar.gz: 90a8b9b0f759a590d3ca2282d02e84aea20c9edc145af3019ee824fc6f2a05c2
3
+ metadata.gz: c5bb3465b2672800174dc8160f4f3dea172fab317cd9c6698c5e9555838830d9
4
+ data.tar.gz: 2c5c68af968df8d0a2b834efa4a3dc15c197a0567848588e9c819a2e7218d0db
5
5
  SHA512:
6
- metadata.gz: 110cdc78d668d1fe44efdff779514d26bbdc83a4d1905bbb920fa9d8edaed02f1bb683694170141714363177f20b73b515629badea8179708e86fe00a3e4bc2a
7
- data.tar.gz: e48613def1a278613a88e99575552425c3d6b1e8a6674e4b5f08010489ae5a608e730b09e0e9e46600b9543028c72ebf101528d1b4032e916458057e517c5773
6
+ metadata.gz: 75c793e1f932297cee9148c38a8fa85d642861f8e5bf884ec1911842fed7fa8ea19067f61f5a7c98e08eb1efabf44902557261cb72383c8f09723393611e0355
7
+ data.tar.gz: c28d2ccd7ef2588003a20c23c8275bbf3d077c9ecfb2a8cd113fbcc4c45e568c714cdd11a419a492cd291e53dfd08486686ef87612e4ad50cc3051c5773bfdd7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026/11/10)
4
+
5
+ - [#28](https://github.com/dblock/ruby-gem-release/pull/28): Add success callbacks and structured failure callbacks with captured Ruby exceptions to both Actions interfaces - [@dblock](https://github.com/dblock).
6
+ - [#29](https://github.com/dblock/ruby-gem-release/pull/29): Add release validation callbacks and successful skipping for empty pending notes, rename the prepared-release test command input from `validate` to `test` (breaking change), and document migration in [UPGRADING.md](UPGRADING.md) - [@dblock](https://github.com/dblock).
7
+ - [#30](https://github.com/dblock/ruby-gem-release/pull/30): Rename preparation and next development iteration callbacks and Rake tasks to `prepare` and `bump`, matching the Actions command inputs (breaking change) - [@dblock](https://github.com/dblock).
8
+ - [#31](https://github.com/dblock/ruby-gem-release/pull/31): Pass provider-specific execution contexts to callbacks with step history, workflow access, run metadata, and next development version propagation; separate registry destinations from package construction with a RubyGems implementation (breaking change) - [@dblock](https://github.com/dblock).
9
+ - [#32](https://github.com/dblock/ruby-gem-release/pull/32): Separate execution-platform services into a provider-neutral base and GitHub implementation, delegate environment and failure transport, and separate identity-token acquisition from the RubyGems exchange (breaking change) - [@dblock](https://github.com/dblock).
10
+ - [#33](https://github.com/dblock/ruby-gem-release/pull/33): Verify provider-neutral releases across separate consumer processes without Actions variables, group consumer specs under `spec/integration/` with a `spec:integration` Rake task, and guide README readers from defaults to workflow extensions - [@dblock](https://github.com/dblock).
11
+ - [#35](https://github.com/dblock/ruby-gem-release/pull/35): Add self-release and GitLab semantic-release customization walkthroughs with example-local placeholder providers - [@dblock](https://github.com/dblock).
12
+ - [#36](https://github.com/dblock/ruby-gem-release/pull/36): Move authentication into publication through destination hooks, remove registry-specific YAML steps, and unify RubyGems exchanges with optional owner binding and environment-only credentials; custom publication commands must not rely on a generated credentials file (breaking change) - [@dblock](https://github.com/dblock).
13
+ - [#38](https://github.com/dblock/ruby-gem-release/pull/38): Speed up release tests by separating focused orchestration coverage from real-process consumer smoke tests and avoiding duplicate source and installed integration runs in CI - [@dblock](https://github.com/dblock).
14
+ - [#37](https://github.com/dblock/ruby-gem-release/pull/37): Add opt-in semantic releases with reachable-tag history, configurable Conventional Commits rules and versions, persisted immutable decisions, generated notes and changelog files, and optional development bumps - [@dblock](https://github.com/dblock).
15
+ - [#39](https://github.com/dblock/ruby-gem-release/pull/39): Preserve configured packages during context capture, share workflow installation and coherent state snapshots, and streamline release and migration documentation - [@dblock](https://github.com/dblock).
16
+ - [#40](https://github.com/dblock/ruby-gem-release/pull/40): Make release tasks independently idempotent with source-commit timestamps, checksum-verified publication, reusable Git refs, and replaceable hosted notes/assets; fail empty-release validation and remove the decision-output protocol (breaking change) - [@dblock](https://github.com/dblock).
17
+ - [#41](https://github.com/dblock/ruby-gem-release/pull/41): Isolate release tests from inherited release context so this project's self-release can run its test suite - [@dblock](https://github.com/dblock).
18
+ - [#42](https://github.com/dblock/ruby-gem-release/pull/42): Standardize environment names into consistent input, gemspec, source, release, and registry families, and rename `release.previous_version` and its constructor keyword to `gemspec_version` to distinguish the captured gemspec version from release history (breaking change) - [@dblock](https://github.com/dblock).
19
+ - [#43](https://github.com/dblock/ruby-gem-release/pull/43): Remove unused output-writing, strict availability, and Git/hosting readback methods and legacy state support; report failures from one coherent state snapshot (breaking change) - [@dblock](https://github.com/dblock).
20
+ - [#44](https://github.com/dblock/ruby-gem-release/pull/44): Share local commit, build, and tag preparation between artifact checkpoints and publication while preserving independent, idempotent publication - [@dblock](https://github.com/dblock).
21
+ - [#45](https://github.com/dblock/ruby-gem-release/pull/45): Remove the unused state format version and validate only invocation-local analysis and next-version fields - [@dblock](https://github.com/dblock).
22
+ - [#47](https://github.com/dblock/ruby-gem-release/pull/47): Remove obsolete step-output parsing, registry lookup instrumentation, package name access, and the unused release argument from Git branch checks; verify custom registry publication by archive identity instead of lookup counts (breaking change) - [@dblock](https://github.com/dblock).
23
+ - [#46](https://github.com/dblock/ruby-gem-release/pull/46): Correct rerun and preparation-order documentation, remove obsolete migration notes from reference pages, and keep upgrade guidance in the migration guide - [@dblock](https://github.com/dblock).
24
+ - [#48](https://github.com/dblock/ruby-gem-release/pull/48): Update this project's action and reusable workflow pins in every Markdown file under `docs/`, alongside README, to the released tag during the next development iteration - [@dblock](https://github.com/dblock).
25
+
3
26
  ## 0.4.0 (2026/11/08)
4
27
 
5
28
  - [#24](https://github.com/dblock/ruby-gem-release/pull/24): Reject empty release notes in both built-in changelog formats before writing release updates, ignoring placeholders, category headings, comments, and reference definitions - [@dblock](https://github.com/dblock).
data/README.md CHANGED
@@ -6,18 +6,9 @@
6
6
 
7
7
  **Release your Ruby gem from GitHub Actions without maintaining custom release scripts or storing a RubyGems API key.**
8
8
 
9
- Update your changelog, validate and publish your gem, create its GitHub Release, and prepare the next development iteration with one workflow dispatch. Choose your version and changelog formats; the standard workflow handles the release sequence.
9
+ One dispatch updates the changelog, tests and publishes the gem, creates its GitHub Release, and prepares the next development iteration. Choose your version and changelog formats; the workflow handles the sequence.
10
10
 
11
- - **No stored RubyGems API key:** Authenticate with RubyGems Trusted Publishing using OpenID Connect (OIDC).
12
- - **Let collaborators request releases:** After one-time setup, collaborators with write access, including on personal repositories, can dispatch releases without repository admin access or their own RubyGems credentials. [Environment protection](RELEASING.md#maintainer-approved-publication) can require approval from a release maintainer before publication.
13
- - **Keep your conventions:** Use the standard workflow or compose Ruby objects and callbacks for custom releases.
14
- - **Choose the release version:** Release the checked-in version or [select a different version](docs/actions/release.md#release-version) when dispatching, without a separate version-bump commit.
15
- - **Catch release mistakes:** [Check for already published RubyGems versions](docs/package/ruby_gem.md#check-version), reject existing tags, and [require actual release notes](docs/changelog/base.md#release) in both built-in changelog formats.
16
- - **Keep dependency files out of release commits:** [Reject dirty checkouts after dependency installation](docs/source_control/git.md) and unexpected working tree or index changes after preparation, instead of staging unrelated files during publication.
17
- - **Build once and retain recovery files:** Publish the same verified gem artifact to RubyGems and attach it to the GitHub Release. Preserve available gem, checksum manifest, and Git recovery bundle files for [recovery after failures](docs/actions/release.md#recovery-artifacts).
18
- - **Use your release timezone:** [Configure release dates](docs/actions/release.md#timezone) with a named timezone such as `America/New_York`, including daylight saving changes; UTC is the default.
19
-
20
- Requires Ruby 3.3+, GitHub Actions, and RubyGems Trusted Publishing.
11
+ The default setup below requires Ruby 3.3+, GitHub Actions, and RubyGems Trusted Publishing.
21
12
 
22
13
  ## Usage
23
14
 
@@ -35,7 +26,7 @@ RubyGemRelease::Workflow::RubyGem.new(
35
26
  ).install_tasks
36
27
  ```
37
28
 
38
- The standard workflow handles changelog updates, publication, and the next development iteration, selecting the gemspec and repository using unambiguous defaults. See [Detailed Usage](docs/usage.md) for file examples and custom release callbacks.
29
+ These defaults select the gemspec and repository, reject empty pending notes, prepare the changelog, publish to RubyGems and GitHub Releases, and increment the patch version for the next development iteration. Dates use UTC.
39
30
 
40
31
  ### 2. Add the Release Workflow
41
32
 
@@ -62,7 +53,7 @@ permissions:
62
53
 
63
54
  jobs:
64
55
  release:
65
- uses: dblock/ruby-gem-release/.github/workflows/release.yml@v0.3.1
56
+ uses: dblock/ruby-gem-release/.github/workflows/release.yml@v0.4.0
66
57
  with:
67
58
  ruby-version: '3.3'
68
59
  version: ${{ inputs.version }}
@@ -79,6 +70,8 @@ gh api --method PUT repos/{owner}/{repo}/environments/release
79
70
 
80
71
  For a new gem, open the [pending trusted publishers page](https://rubygems.org/profile/oidc/pending_trusted_publishers). For an existing gem, open your existing gem's **Trusted publishers** page (e.g. `https://rubygems.org/gems/example/trusted_publishers`). Create a [trusted publisher](https://guides.rubygems.org/trusted-publishing/#using-reusable-workflows). Set the repository fields to your gem's repository, the workflow filename to `release.yml`, and the environment to `release`. Set the separate workflow repository fields to `dblock` and `ruby-gem-release`; these identify the reusable workflow RubyGems verifies in the OIDC token.
81
72
 
73
+ Collaborators with write access can dispatch releases after this setup, without repository admin access or their own RubyGems credentials. Creating the environment alone does not restrict publication. Configure [required maintainer approval and deployment branches](RELEASING.md#maintainer-approved-publication) to control who approves publication.
74
+
82
75
  ### 4. Make a Release
83
76
 
84
77
  Your branch rules must permit release commits and tags to be pushed; see [branch permissions](RELEASING.md#one-time-setup). Dispatching starts a publishing run; when required reviewers are configured, the release job waits for approval from a designated maintainer. Run your project's tests and build checks, review the changes, and dispatch from your default branch:
@@ -99,15 +92,27 @@ To release a different version, select it in the dispatch form or pass `-f versi
99
92
 
100
93
  For a real-world example of adding release automation to an existing gem, see [ruby-link-checker #26](https://github.com/dblock/ruby-link-checker/pull/26). It adds the dependency, Rake tasks, reusable workflow, and release instructions while preserving the existing changelog format.
101
94
 
102
- ## Configuration
95
+ ## Extending the Workflow
96
+
97
+ Replace only what you need. The [standard workflow](docs/workflow/ruby_gem.md) preserves ordering; [Detailed Usage](docs/usage.md) provides file examples and complete manual callbacks.
98
+
99
+ - **Project conventions:** Choose [version objects](docs/usage.md#version-objects) and [changelog formats](docs/usage.md#changelog-objects), including custom implementations.
100
+ - **Release decisions and callbacks:** Customize [validation, preparation, publication, and notifications](docs/tasks.md). Callbacks receive an [execution context](docs/execution_context.md) with release data, step history, and the configured workflow.
101
+ - **Semantic releases:** Opt into [Conventional Commits, release-history-based versions, and generated notes](docs/workflow/semantic_ruby_gem.md) without changing the execution platform or registry.
102
+ - **Packages and registries:** Override [package construction](docs/package/ruby_gem.md) or supply a [registry destination](docs/destination.md) independently of hosted releases.
103
+ - **Release hosting:** Replace or disable [GitHub Release creation](docs/workflow/ruby_gem.md#hosting) without changing package publication or Git operations.
104
+ - **Execution platforms:** Supply an [execution provider](docs/execution_provider.md) for environment transport, metadata, temporary storage, and authentication services. GitHub Actions is the only built-in external platform; another platform needs its own adapter and pipeline.
105
+ - **Actions integration:** Override commands, inputs, and timezone through the [Actions Reference](docs/actions/release.md), or use the [Composite Action](docs/actions/action.md) inside a caller-owned job.
106
+
107
+ Execution platform, registry, and hosting are independent choices.
103
108
 
104
- See [Detailed Usage](docs/usage.md#configuration) for workflow customization, callbacks, and available classes, and the [Actions Reference](docs/actions/release.md) for inputs and defaults.
109
+ For complete examples, see [releasing ruby-gem-release itself](examples/self_release/README.md) with small project-specific customizations, then [GitLab and semantic releases](examples/gitlab_semantic_release/README.md) for built-in semantic providers composed with placeholder GitLab adapters.
105
110
 
106
- For step-based integration, see the [Composite Action](docs/actions/action.md).
111
+ The defaults retain [clean-checkout checks](docs/source_control/git.md), [duplicate-version checks](docs/destination/ruby_gems.md#check-version), and [verified recovery artifacts](docs/actions/release.md#recovery-artifacts). Custom publication callbacks must preserve the documented ordering and artifact checks.
107
112
 
108
- ### Release Access
113
+ ## Upgrading
109
114
 
110
- Creating the environment without protection rules does not restrict release access. For maintainer-only publication, configure **Settings → Environments → release** with required maintainer reviewers, self-review and administrator-bypass rules, and deployment branches restricted to `main` (or your actual default branch), as described in [Maintainer-Approved Publication](RELEASING.md#maintainer-approved-publication). Other repository writers can still dispatch, but publication waits for a designated maintainer to approve; environments do not provide a workflow-dispatch allow list.
115
+ See [UPGRADING.md](UPGRADING.md) for breaking changes and migration steps. Upgrade the gem and pinned workflow or action together.
111
116
 
112
117
  ## Contributing
113
118
 
@@ -11,15 +11,39 @@ module RubyGemRelease
11
11
  end
12
12
 
13
13
  def release!(release)
14
+ validate_release(release.version, release.date)
15
+ return if released?(release.version)
16
+
14
17
  update_release(version: release.version, date: release.date)
15
18
  end
16
19
 
20
+ def released?(version)
21
+ validate_version(version)
22
+ return false unless File.file?(@path)
23
+
24
+ matches = heading_positions(File.read(@path), release_pattern(version))
25
+ raise ArgumentError, "duplicate release headings in #{@path}" if matches.size > 1
26
+
27
+ matches.one?
28
+ end
29
+
17
30
  def prepare_next!(release)
18
31
  update_next(version: release.next_version, released_version: release.version)
19
32
  end
20
33
 
34
+ def pending?
35
+ content = File.read(@path)
36
+ notes?(document(unreleased_notes(content, newline(content))))
37
+ end
38
+
21
39
  private
22
40
 
41
+ def unreleased_notes(content, separator)
42
+ position = heading(content, self.class::UNRELEASED)
43
+ section_end = section_end_position(content, position, heading_prefix(content, position).length)
44
+ content[line_end(content, position, separator)...section_end]
45
+ end
46
+
23
47
  def heading(content, pattern)
24
48
  matches = heading_positions(content, pattern)
25
49
  raise ArgumentError, "expected exactly one matching heading in #{@path}" unless matches.one?
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module RubyGemRelease
6
+ module Changelog
7
+ class Generated < Base
8
+ def release!(release)
9
+ check_generated_notes(release)
10
+ content = File.exist?(@path) ? File.read(@path) : "# Changelog\n"
11
+ separator = newline(content)
12
+ return verify_notes(content, release) if released?(release.version)
13
+
14
+ File.write(@path, insert_release(content, release, separator))
15
+ end
16
+
17
+ def prepare_next!(_release); end
18
+
19
+ private
20
+
21
+ def release_pattern(version)
22
+ /\A## #{Regexp.escape(version)}(?: |\z)/
23
+ end
24
+
25
+ def verify_notes(content, release)
26
+ position = heading(content, release_pattern(release.version))
27
+ finish = section_end_position(content, position, 2)
28
+ notes = content[line_end(content, position, newline(content))...finish].strip.gsub("\r\n", "\n")
29
+ raise ArgumentError, 'existing generated release notes differ' unless notes == release.notes.strip
30
+ end
31
+
32
+ def check_generated_notes(release)
33
+ validate_release(release.version, release.date)
34
+ raise ArgumentError, 'generated release notes are required' unless release.notes.is_a?(String)
35
+
36
+ check_notes(release.notes, release.version)
37
+ end
38
+
39
+ def insert_release(content, release, separator)
40
+ sections = heading_positions(content, /\A## /, max_level: 2)
41
+ position = sections.first || content.length
42
+ entry = "## #{release.version} (#{release.date.strftime('%Y/%m/%d')})\n\n#{release.notes.strip}\n\n".gsub("\n", separator)
43
+ prefix = content[...position].sub(/(?:\r?\n)*\z/, "#{separator}#{separator}")
44
+ prefix + entry + content[position..]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -15,6 +15,10 @@ module RubyGemRelease
15
15
 
16
16
  private
17
17
 
18
+ def unreleased_notes(content, separator)
19
+ remove_placeholder(super, separator)
20
+ end
21
+
18
22
  def update_release(version:, date:)
19
23
  validate_release(version, date)
20
24
  content = File.read(@path)
@@ -36,23 +40,33 @@ module RubyGemRelease
36
40
  pattern = %r{\A\#{1,6} #{Regexp.escape(released_version)} \(\d{4}/\d{2}/\d{2}\)\z}
37
41
  position = heading(content, pattern)
38
42
  prefix = heading_prefix(content, position)
43
+ return if heading_positions(content, /\A\#{1,6} #{Regexp.escape(version)} \(Next\)\z/).one?
44
+
39
45
  check_absent(content, UNRELEASED, 'unreleased heading already exists')
40
46
 
41
47
  File.write(@path, content.insert(position, ["#{prefix} #{version} (Next)", '', @placeholder, '', ''].join(separator)))
42
48
  end
43
49
 
44
50
  def check_release_absent(content, version)
45
- check_absent(content, %r{\A\#{1,6} #{Regexp.escape(version)} \(\d{4}/\d{2}/\d{2}\)\z}, 'release heading already exists')
51
+ check_absent(content, release_pattern(version), 'release heading already exists')
52
+ end
53
+
54
+ def release_pattern(version)
55
+ %r{\A\#{1,6} #{Regexp.escape(version)} \(\d{4}/\d{2}/\d{2}\)\z}
46
56
  end
47
57
 
48
58
  def release_notes(section, separator, version)
59
+ check_notes(remove_placeholder(section, separator), version)
60
+ end
61
+
62
+ def remove_placeholder(section, separator)
49
63
  positions = []
50
64
  editable_lines(section) { |line, offset| positions << offset if line.chomp == @placeholder }
51
65
  raise ArgumentError, "expected exactly one placeholder in #{@path}" unless positions.one?
52
66
 
53
67
  position = positions.first
54
68
  section.slice!(position, placeholder_length(section, position, separator))
55
- check_notes(section, version)
69
+ section
56
70
  end
57
71
 
58
72
  def placeholder_length(section, position, separator)
@@ -7,6 +7,16 @@ module RubyGemRelease
7
7
 
8
8
  private
9
9
 
10
+ def release_pattern(version)
11
+ /\A\#{1,6} \[#{Regexp.escape(version)}\] - \d{4}-\d{2}-\d{2}\z/
12
+ end
13
+
14
+ def unreleased_notes(content, separator)
15
+ reference = unreleased_reference(content)
16
+ comparison(reference[2]) if reference
17
+ super
18
+ end
19
+
10
20
  def update_release(version:, date:)
11
21
  validate_release(version, date)
12
22
  content = File.read(@path)
@@ -24,11 +34,12 @@ module RubyGemRelease
24
34
  validate_version(released_version)
25
35
  content = File.read(@path)
26
36
  separator = newline(content)
27
- pattern = /\A\#{1,6} \[#{Regexp.escape(released_version)}\] - \d{4}-\d{2}-\d{2}\z/
28
- position = heading(content, pattern)
37
+ position = heading(content, release_pattern(released_version))
29
38
  prefix = heading_prefix(content, position)
30
- check_absent(content, UNRELEASED, 'unreleased heading already exists')
31
39
  check_next_reference(content, released_version)
40
+ return if heading_positions(content, UNRELEASED).one?
41
+
42
+ check_absent(content, UNRELEASED, 'duplicate unreleased headings')
32
43
 
33
44
  File.write(@path, content.insert(position, ["#{prefix} [Unreleased]", '', ''].join(separator)))
34
45
  end
@@ -3,3 +3,4 @@
3
3
  require_relative 'changelog/base'
4
4
  require_relative 'changelog/intridea'
5
5
  require_relative 'changelog/keep_a_changelog'
6
+ require_relative 'changelog/generated'
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'open3'
6
+ require 'rubygems/package'
7
+ require 'json'
8
+ require 'digest'
9
+ require_relative '../destination'
10
+ require_relative '../workflow/ruby_gems_credentials'
11
+
12
+ module RubyGemRelease
13
+ class Destination
14
+ class RubyGems < Destination
15
+ def authenticate!(provider:)
16
+ return unless provider.fetch('RELEASE_AUTH_MODE', '') == authentication
17
+
18
+ Workflow::RubyGemsCredentials.new(owner: provider.fetch('RUBYGEMS_OWNER_ID', ''), provider: provider).configure!
19
+ end
20
+
21
+ def authentication
22
+ 'rubygems'
23
+ end
24
+
25
+ def publish!(release, package:)
26
+ path = package.artifact(release)
27
+ return if published?(release, package: package)
28
+
29
+ push(path, release: release, package: package)
30
+ end
31
+
32
+ def published?(release, package:)
33
+ path = package.artifact(release)
34
+ spec = Gem::Package.new(path).spec
35
+ raise 'Package version does not match release' unless spec.version.to_s == release.version
36
+
37
+ matching_publication?(spec, path)
38
+ end
39
+
40
+ private
41
+
42
+ def matching_publication?(spec, path)
43
+ response = version_response(spec.name, spec.version.to_s)
44
+ return false if response.code == '404'
45
+ raise "Cannot verify RubyGems publication (HTTP #{response.code})" unless response.code == '200'
46
+
47
+ metadata = JSON.parse(response.body)
48
+ checksum = metadata['sha'] if metadata.is_a?(Hash)
49
+ raise 'Published gem checksum differs or is unavailable; refusing to republish, see RELEASING.md for recovery' unless checksum == Digest::SHA256.file(path).hexdigest
50
+
51
+ true
52
+ end
53
+
54
+ def push(path, release:, package:)
55
+ output, error, status = Open3.capture3(Gem.ruby, '-S', 'gem', 'push', path)
56
+ raise "Destination command failed: #{error.strip}\n#{output.strip}" unless status.success? || published?(release, package: package)
57
+
58
+ output
59
+ end
60
+
61
+ def version_response(name, version)
62
+ uri = URI("https://rubygems.org/api/v2/rubygems/#{URI.encode_www_form_component(name)}/versions/#{URI.encode_www_form_component(version)}.json")
63
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30, max_retries: 0) { |http| http.get(uri.request_uri) }
64
+ rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
65
+ raise "Cannot check RubyGems for #{name} #{version}: #{e.message}; verify connectivity before retrying"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyGemRelease
4
+ class Destination
5
+ def authenticate!(provider:); end
6
+
7
+ def publish!(release, package:)
8
+ raise NotImplementedError, "#{self.class} must implement publish!"
9
+ end
10
+
11
+ def published?(_release, **)
12
+ raise ArgumentError, "#{self.class} must implement published? to verify recovery"
13
+ end
14
+
15
+ def authentication
16
+ nil
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../execution_context'
4
+
5
+ module RubyGemRelease
6
+ class ExecutionContext
7
+ class GitHub < ExecutionContext
8
+ class Run
9
+ attr_reader :repository, :ref, :sha, :run_id, :run_attempt, :job_id, :workflow_name, :workflow_ref
10
+
11
+ def initialize(environment: ENV)
12
+ @repository = value(environment, 'GITHUB_REPOSITORY')
13
+ @ref = value(environment, 'GITHUB_REF')
14
+ @sha = value(environment, 'GITHUB_SHA')
15
+ @run_id = value(environment, 'GITHUB_RUN_ID')
16
+ @run_attempt = value(environment, 'GITHUB_RUN_ATTEMPT')
17
+ @job_id = value(environment, 'GITHUB_JOB')
18
+ @workflow_name = value(environment, 'GITHUB_WORKFLOW')
19
+ @workflow_ref = value(environment, 'GITHUB_WORKFLOW_REF')
20
+ freeze
21
+ end
22
+
23
+ private
24
+
25
+ def value(environment, name)
26
+ environment[name]&.dup&.freeze
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../execution_context'
4
+
5
+ module RubyGemRelease
6
+ class ExecutionContext
7
+ class GitHub < ExecutionContext
8
+ class Step < ExecutionContext::Step
9
+ attr_reader :outcome, :conclusion
10
+
11
+ def initialize(id:, outcome: nil, conclusion: nil)
12
+ @outcome = status(outcome)
13
+ @conclusion = status(conclusion)
14
+ super(id: id)
15
+ end
16
+
17
+ private
18
+
19
+ def status(value)
20
+ return if value.nil?
21
+ raise ArgumentError, "invalid step status: #{value.inspect}" unless %w[success failure cancelled skipped].include?(value)
22
+
23
+ value.dup.freeze
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../execution_context'
5
+ require_relative 'github/step'
6
+ require_relative 'github/run'
7
+
8
+ module RubyGemRelease
9
+ class ExecutionContext
10
+ class GitHub < ExecutionContext
11
+ def self.step_id(task, environment: ENV)
12
+ environment.fetch('RELEASE_STEP_ID', task)
13
+ end
14
+
15
+ def self.steps(environment: ENV)
16
+ attributes = JSON.parse(environment.fetch('RELEASE_STEPS_JSON', '{}'))
17
+ raise ArgumentError, 'release steps must be an object' unless attributes.is_a?(Hash)
18
+
19
+ attributes.to_h do |id, values|
20
+ raise ArgumentError, 'release step must be an object' unless values.is_a?(Hash)
21
+
22
+ [id.freeze, Step.new(id: id, outcome: values['outcome'], conclusion: values['conclusion'])]
23
+ end.freeze
24
+ end
25
+
26
+ def initialize(release:, task:, state:, workflow: nil, environment: ENV, analysis: state&.analysis)
27
+ super(release: release, task: task, state: state, workflow: workflow, analysis: analysis,
28
+ step: Step.new(id: self.class.step_id(task, environment: environment)),
29
+ steps: self.class.steps(environment: environment), run: Run.new(environment: environment))
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyGemRelease
4
+ class ExecutionContext
5
+ class Step
6
+ attr_reader :id
7
+
8
+ def initialize(id:)
9
+ raise ArgumentError, 'step ID must be a nonempty string' unless id.is_a?(String) && !id.empty?
10
+
11
+ @id = id.dup.freeze
12
+ freeze
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'execution_context/step'
4
+
5
+ module RubyGemRelease
6
+ class ExecutionContext
7
+ attr_reader :release, :step, :steps, :workflow, :run, :analysis
8
+
9
+ def self.step_id(task)
10
+ task
11
+ end
12
+
13
+ def initialize(release:, task:, state:, workflow: nil, step: Step.new(id: task), steps: {}, run: nil, analysis: state&.analysis)
14
+ @release = release
15
+ @workflow = workflow
16
+ @state = state
17
+ @step = step
18
+ @steps = steps.dup.freeze
19
+ @run = run
20
+ @analysis = analysis
21
+ freeze
22
+ end
23
+
24
+ def record_next_version!(version)
25
+ @state.record_next_version!(version)
26
+ end
27
+
28
+ def record_analysis!(decision)
29
+ @state.record_analysis!(decision)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require_relative '../execution_provider'
7
+ require_relative '../execution_context/github'
8
+
9
+ module RubyGemRelease
10
+ class ExecutionProvider
11
+ class GitHub < ExecutionProvider
12
+ def directory
13
+ value = fetch('RUNNER_TEMP', nil)
14
+ value unless value.to_s.empty?
15
+ end
16
+
17
+ def ref
18
+ fetch('GITHUB_REF')
19
+ end
20
+
21
+ def git_identity
22
+ { name: fetch('GIT_USER_NAME', 'github-actions[bot]'), email: fetch('GIT_USER_EMAIL', 'github-actions[bot]@users.noreply.github.com') }
23
+ end
24
+
25
+ def export(values)
26
+ append_file('GITHUB_ENV', values)
27
+ super
28
+ end
29
+
30
+ def context(**attributes)
31
+ ExecutionContext::GitHub.new(**attributes, environment: @environment)
32
+ end
33
+
34
+ def step_id(task)
35
+ ExecutionContext::GitHub.step_id(task, environment: @environment)
36
+ end
37
+
38
+ def failed_steps
39
+ ExecutionContext::GitHub.steps(environment: @environment).select { |_id, step| step.outcome == 'failure' }.keys
40
+ end
41
+
42
+ def oidc_token(audience:)
43
+ body = oidc_response(oidc_request(audience))
44
+ token = body['value'] if body.is_a?(Hash)
45
+ raise 'OIDC response did not contain a valid token' unless token.is_a?(String) && !token.empty? && !token.match?(/[\r\n]/)
46
+
47
+ token
48
+ end
49
+
50
+ def mask(value)
51
+ puts "::add-mask::#{value.gsub('%', '%25').gsub("\r", '%0D').gsub("\n", '%0A')}"
52
+ end
53
+
54
+ private
55
+
56
+ def append_file(name, values)
57
+ validate_values(values)
58
+ File.open(fetch(name), 'a') { |file| values.each { |key, value| file.puts "#{key}=#{value}" } }
59
+ end
60
+
61
+ def oidc_request(audience)
62
+ uri = URI(fetch('ACTIONS_ID_TOKEN_REQUEST_URL'))
63
+ raise ArgumentError, 'OIDC request URL must use HTTPS' unless uri.is_a?(URI::HTTPS)
64
+
65
+ query = URI.decode_www_form(uri.query || '').reject { |name, _value| name == 'audience' }
66
+ uri.query = URI.encode_www_form(query + [['audience', audience]])
67
+ Net::HTTP::Get.new(uri).tap { |request| request['Authorization'] = "Bearer #{fetch('ACTIONS_ID_TOKEN_REQUEST_TOKEN')}" }
68
+ end
69
+
70
+ def oidc_response(request)
71
+ uri = request.uri
72
+ result = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30, max_retries: 0) { |http| http.request(request) }
73
+ raise "OIDC request failed (HTTP #{result.code})" unless result.is_a?(Net::HTTPSuccess)
74
+
75
+ JSON.parse(result.body)
76
+ rescue JSON::ParserError
77
+ raise 'OIDC response was not valid JSON'
78
+ end
79
+ end
80
+ end
81
+ end