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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +23 -18
- data/lib/ruby-gem-release/changelog/base.rb +24 -0
- data/lib/ruby-gem-release/changelog/generated.rb +48 -0
- data/lib/ruby-gem-release/changelog/intridea.rb +16 -2
- data/lib/ruby-gem-release/changelog/keep_a_changelog.rb +14 -3
- data/lib/ruby-gem-release/changelog.rb +1 -0
- data/lib/ruby-gem-release/destination/ruby_gems.rb +69 -0
- data/lib/ruby-gem-release/destination.rb +19 -0
- data/lib/ruby-gem-release/execution_context/github/run.rb +31 -0
- data/lib/ruby-gem-release/execution_context/github/step.rb +28 -0
- data/lib/ruby-gem-release/execution_context/github.rb +33 -0
- data/lib/ruby-gem-release/execution_context/step.rb +16 -0
- data/lib/ruby-gem-release/execution_context.rb +32 -0
- data/lib/ruby-gem-release/execution_provider/github.rb +81 -0
- data/lib/ruby-gem-release/execution_provider.rb +65 -0
- data/lib/ruby-gem-release/github_api.rb +35 -0
- data/lib/ruby-gem-release/hosting/github_release/existing.rb +64 -0
- data/lib/ruby-gem-release/hosting/github_release.rb +52 -6
- data/lib/ruby-gem-release/package/ruby_gem.rb +22 -37
- data/lib/ruby-gem-release/release.rb +15 -9
- data/lib/ruby-gem-release/release_failure.rb +36 -0
- data/lib/ruby-gem-release/semantic_release/conventional_commits.rb +102 -0
- data/lib/ruby-gem-release/semantic_release/decision.rb +111 -0
- data/lib/ruby-gem-release/semantic_release/git_history.rb +43 -0
- data/lib/ruby-gem-release/semantic_release/release_notes.rb +40 -0
- data/lib/ruby-gem-release/semantic_release/version_selector.rb +48 -0
- data/lib/ruby-gem-release/semantic_release.rb +28 -0
- data/lib/ruby-gem-release/source_control/git/preparation.rb +1 -1
- data/lib/ruby-gem-release/source_control/git.rb +117 -21
- data/lib/ruby-gem-release/tasks.rb +190 -32
- data/lib/ruby-gem-release/version/ruby_constant.rb +7 -3
- data/lib/ruby-gem-release/version.rb +1 -1
- data/lib/ruby-gem-release/workflow/environment.rb +110 -0
- data/lib/ruby-gem-release/workflow/failure.rb +47 -0
- data/lib/ruby-gem-release/workflow/ruby_gem.rb +57 -13
- data/lib/ruby-gem-release/workflow/ruby_gems_credentials.rb +13 -26
- data/lib/ruby-gem-release/workflow/semantic_ruby_gem.rb +64 -0
- data/lib/ruby-gem-release/workflow/state.rb +94 -0
- metadata +25 -3
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5bb3465b2672800174dc8160f4f3dea172fab317cd9c6698c5e9555838830d9
|
|
4
|
+
data.tar.gz: 2c5c68af968df8d0a2b834efa4a3dc15c197a0567848588e9c819a2e7218d0db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
+
## Upgrading
|
|
109
114
|
|
|
110
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|