From 74008c42e99050e5bbc4fd79b34b515c3bec30f2 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Sun, 13 Sep 2026 08:26:08 -0700 Subject: [PATCH 1/2] ci: replace Thor releases with Release Please and trusted publishing --- .github/workflows/publish-gem.yml | 84 +++++++++++++ .github/workflows/release-please.yml | 55 +++++++++ .github/workflows/release-validation.yml | 34 ++++++ .release-please-manifest.json | 3 + .rubocop.yml | 2 +- CHANGELOG.md | 8 ++ Gemfile | 3 +- RELEASING.md | 149 +++++++++++++++++++++++ Thorfile | 20 --- appium_console.gemspec | 6 + lib/appium_console/version.rb | 2 +- readme.md | 5 + release-please-config.json | 17 +++ script/release.rb | 76 ++++++++++++ test/release/release_test.rb | 116 ++++++++++++++++++ 15 files changed, 557 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/publish-gem.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/release-validation.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 RELEASING.md delete mode 100644 Thorfile create mode 100644 release-please-config.json create mode 100644 script/release.rb create mode 100644 test/release/release_test.rb diff --git a/.github/workflows/publish-gem.yml b/.github/workflows/publish-gem.yml new file mode 100644 index 0000000..c80299b --- /dev/null +++ b/.github/workflows/publish-gem.yml @@ -0,0 +1,84 @@ +name: Publish gem + +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag to publish or retry (for example v13.1.1) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-gem-${{ inputs.tag }} + cancel-in-progress: false + +env: + RELEASE_TAG: ${{ inputs.tag }} + +jobs: + verify: + if: github.repository == 'appium/ruby_console' + runs-on: ubuntu-latest + outputs: + gem_path: ${{ steps.package.outputs.gem_path }} + steps: + - name: Validate tag syntax + shell: bash + run: | + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then + echo 'Expected a release tag such as v13.1.1 or v13.2.0-rc.1' >&2 + exit 1 + fi + - uses: actions/checkout@v7 + with: + ref: refs/tags/${{ env.RELEASE_TAG }} + fetch-depth: 0 + persist-credentials: false + - name: Verify release commit belongs to master + run: git merge-base --is-ancestor HEAD origin/master + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + # Build before tools/tests can generate or update local files. + - name: Build gem from the tagged checkout + run: bundle exec rake build + - name: Run checks on the release commit + run: | + bundle exec rake rubocop + bundle exec ruby -Ilib bin/arc version + bundle exec ruby test/release/release_test.rb + - name: Validate the built gem + id: package + run: ruby script/release.rb verify "$RELEASE_TAG" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 + with: + name: release-gem + path: ${{ steps.package.outputs.gem_path }} + if-no-files-found: error + retention-days: 14 + + publish: + needs: verify + runs-on: ubuntu-latest + environment: rubygems + permissions: + contents: read + id-token: write + steps: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + - uses: actions/download-artifact@v4 + with: + name: release-gem + path: pkg + - name: Authenticate with RubyGems Trusted Publishing + uses: rubygems/configure-rubygems-credentials@daab0479595bcc124493903b3df17273d86e073f + - name: Publish the verified gem + env: + GEM_PATH_TO_PUBLISH: ${{ needs.verify.outputs.gem_path }} + run: gem push --host https://rubygems.org "$GEM_PATH_TO_PUBLISH" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..60a5642 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,55 @@ +name: Release Please + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-please + cancel-in-progress: false + +jobs: + release: + if: github.repository == 'appium/ruby_console' + permissions: + contents: write + pull-requests: write + issues: write + actions: write + runs-on: ubuntu-latest + env: + TZ: UTC + steps: + - name: Update release PR or create GitHub Release + id: release + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + target-branch: master + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # GITHUB_TOKEN does not automatically start downstream workflows. + # Explicit dispatch runs checks on the release PR commit without a bot key. + - name: Run release PR checks + if: steps.release.outputs.prs_created == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RELEASE_PR: ${{ steps.release.outputs.pr }} + run: | + branch=$(jq -er '.headBranchName | select(type == "string" and length > 0)' <<< "$RELEASE_PR") + for workflow in release-validation.yml; do + gh workflow run "$workflow" --ref "$branch" + done + - name: Publish the new release + if: steps.release.outputs.release_created == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ steps.release.outputs.tag_name }} + run: gh workflow run publish-gem.yml --ref master -f "tag=$RELEASE_TAG" diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml new file mode 100644 index 0000000..b7c0937 --- /dev/null +++ b/.github/workflows/release-validation.yml @@ -0,0 +1,34 @@ +name: Release validation + +on: + workflow_dispatch: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + - name: Test release validation + run: | + ruby -c script/release.rb + bundle exec ruby test/release/release_test.rb + - name: Check console + run: | + bundle exec rake rubocop + bundle exec ruby -Ilib bin/arc version + - name: Build and validate gem + run: | + bundle exec rake build + ruby script/release.rb verify diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..34a3350 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "4.2.0" +} diff --git a/.rubocop.yml b/.rubocop.yml index f999288..3141405 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,6 @@ AllCops: TargetRubyVersion: 3.1 -Metrics/LineLength: +Layout/LineLength: Max: 128 Metrics/MethodLength: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be12a15 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +Historical commit-level notes through 4.2.0 are in [release_notes.md](release_notes.md). +New entries are maintained by Release Please. + +## [4.2.0] - 2025-01-25 + +- See the historical release notes for this release. diff --git a/Gemfile b/Gemfile index 604eed8..03c103b 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,7 @@ source 'https://rubygems.org' gemspec -gem 'appium_thor', '~> 2.0' gem 'rake', '~> 13.0' gem 'rubocop', '1.90.0' + +gem 'minitest', '~> 5.0' diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..f6dfc03 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,149 @@ +# Releasing appium_console + +Release Please prepares the release PR, version, changelog, tag, and GitHub Release. +GitHub Actions builds and tests the tagged code, then publishes that exact gem to +RubyGems.org using Trusted Publishing. `appium_thor` is no longer required. + +After the one-time setup, the maintainer's normal release action is to review and +merge the release PR. There is no local version bump, tag push, or gem push. + +## One-time setup + +1. In repository **Settings → Actions → General → Workflow permissions**, enable + **Allow GitHub Actions to create and approve pull requests** (the organization + policy must permit it). Workflows declare their own permissions. Release Please + uses the built-in `GITHUB_TOKEN`; no GitHub App, PAT, or new GitHub secret is needed. +2. Keep the normal branch protection/rulesets on `master`. Require the `package` job from **Release validation** before merging + a release PR. Do not exempt the release bot from those merge checks. Its direct + writes are to the generated release PR branch and release tags, not master. +3. Create the GitHub Actions environment `rubygems`. Permit release tags (`v*`) and + the `master` branch (for automated publishing and retries). Add a required reviewer only if a + separate approval for each publication is wanted; otherwise merging the release + PR is the normal human decision. +4. As an owner of `appium_console` on RubyGems.org, configure a Trusted Publisher: + - Repository owner: `appium` + - Repository name: `ruby_console` + - Workflow filename: `publish-gem.yml` + - Environment: `rubygems` +5. Merge the migration after its CI passes. A push to master or a manual run of + **Release Please** updates the pending release PR. Configure the RubyGems + publisher before merging that first release PR. + +This follows the standard `GITHUB_TOKEN` and OIDC authentication used by Appium's +Python client and XCUITest driver release workflows. Release Please explicitly +uses `workflow_dispatch` to run the package validation +workflow on the release PR branch, and **Publish gem** on master with the new tag. +These events run with `GITHUB_TOKEN` without a separate bot credential. All workflow +files must first be on master for dispatch to work. + +GitHub may also show approval-required `pull_request` runs for bot-created PRs. +The dispatched package checks do not require that approval. If additional +PR-event-only checks are made required, adapt their triggers as well. + +No credentials, remote settings, tags, or releases are created by installing these +files. Configure the RubyGems publisher before merging the first release PR. + +## Normal release + +1. Merge ordinary changes using Conventional Commit titles/messages. A `fix:` + produces a patch bump, `feat:` a minor bump, and a breaking-change marker such as + `feat!:` a major bump. Chore-only changes do not normally create a release PR. +2. Release Please maintains a PR updating `lib/appium_console/version.rb`, + `.release-please-manifest.json`, and `CHANGELOG.md`. Its standard `x-release-please-date` annotation also updates + `Appium::Console::DATE` in the same PR. Wait for checks on the final PR commit. +3. Review the version and release notes, then merge the PR when ready. If master + has advanced, require current checks before merging through the repository rules. +4. Release Please creates `v` and a GitHub Release, then dispatches + **Publish gem** with that tag. **Publish gem** verifies + that the tag is on master's history, builds the gem before test tools can modify local files, runs checks, and verifies + the tag, source version, manifest, changelog date, and packaged gem identity. +5. Only a successful verification job uploads a gem for the separate publishing + job. That job has the OIDC permission, downloads the verified artifact, and uses + `gem push`. It does not rebuild the package or create another tag. + +A GitHub Release can exist before RubyGems publication succeeds. The **Publish gem** +workflow result is the publication status; creating a GitHub Release alone does +not mean the gem has been published. + +`release_notes.md` is retained as historical commit-level notes through 4.2.0. +New release notes are generated in `CHANGELOG.md` and GitHub Releases. `DATE` is the +release-preparation date recorded in that changelog, not the later RubyGems upload +timestamp. No public version/date constant is removed. + +## Choosing a version or publishing an RC + +For a one-off explicit version, use Release Please's `Release-As: 13.2.0` footer in +a commit included in the release, or the `release-as` package setting. If using the +setting, remove it after the release so it does not keep forcing the same version. +Review the generated PR instead of changing only VERSION independently of the +manifest and changelog. + +For an RC, configure the root package with `"prerelease": true` and an explicit +`"release-as": "13.2.0-rc.1"`. Release Please uses a SemVer tag such as +`v13.2.0-rc.1`; RubyGems normalizes the gem version/filename to `13.2.0.pre.rc.1`. +The release validator accepts that mapping. Use `rc.2`, etc. for further RCs. +Before the stable release, remove the prerelease flag and explicit RC version (or +set the intended stable release version explicitly). The default configuration +is for stable releases from master, not a parallel prerelease branch. + +## Recovery + +- **Release PR has a date mismatch:** rerun **Release Please**. Alternatively, check + out its branch, run `ruby script/release.rb prepare-date`, and commit the date + change. Do not edit the date to the upload day independently of the changelog. +- **PR checks do not start:** inspect the **Run release PR checks** step, Actions + policy, and workflow permissions. Each dispatched workflow must exist on master. + Retry the affected workflow from the generated PR branch using **Run workflow**; + rerunning Release Please may not return an unchanged PR in its outputs. +- **Tag/Release exists but publication failed:** correct external setup if needed + and rerun the failed workflow jobs. If dispatch itself failed, run + **Publish gem** from master with the existing tag; rerunning Release Please does + not necessarily emit an already-created release again. The verified artifact is kept for 14 days. + If it has expired, run **Publish gem** manually from master with the same existing + release tag; it rechecks and rebuilds that tag. This retry route is for releases + containing this workflow and validation script, not older historical tags. +- **RubyGems already has that version:** confirm the existing release before retrying. + The workflow deliberately does not treat every push failure as success. Published + versions cannot be overwritten. Do not move the tag or rebuild different source + under the same version; use a new release when a code fix is needed. +- **A build or validation needs a source-code fix:** fix master and prepare a new + release. The retry path always checks out the original tag, so it cannot silently + publish newer master code under that tag. + +## Local checks without publication + +```sh +bundle install +bundle exec rake rubocop +bundle exec ruby -Ilib bin/arc version +bundle exec ruby test/release/release_test.rb +bundle exec rake build +ruby script/release.rb verify +``` + +To validate a prospective tag, pass `v` to `verify`. This checks metadata; +it does not create the tag. `prepare-date` changes only the local version file. +Neither script command pushes or publishes. `bundle exec rake build` only builds. + +The repository bootstrap is pinned to the existing `v4.2.0` commit +`2f63a20264d375195d49b975df974522605c54ce`, with manifest version `4.2.0`. This avoids +replaying all historical changes on the first release. Once Release Please has +made its first release, its bootstrap setting is ignored and can be removed. + +## References + +- [Release Please](https://github.com/googleapis/release-please) +- [GitHub workflow triggering and token behavior](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow) +- [Appium Python client release workflow](https://github.com/appium/python-client/blob/master/.github/workflows/publish.yml) +- [Appium XCUITest driver release workflow](https://github.com/appium/appium-xcuitest-driver/blob/master/.github/workflows/publish.js.yml) +- [Manifest and prerelease configuration](https://github.com/googleapis/release-please/blob/main/docs/manifest-releaser.md) +- [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) +- [RubyGems credentials Action](https://github.com/rubygems/configure-rubygems-credentials) + +The credentials Action is used instead of `rubygems/release-gem` because Release +Please already owns the tag. Authentication plus `gem push` avoids invoking a +second tool's tag-creation/release sequence. + +The runtime `thor` dependency is retained for the `arc` CLI. Only the separate +`appium_thor` release tooling is removed. The old `thor spec` task referred to a +nonexistent `spec/` directory; release checks now run via Minitest directly. diff --git a/Thorfile b/Thorfile deleted file mode 100644 index ea3b5a8..0000000 --- a/Thorfile +++ /dev/null @@ -1,20 +0,0 @@ -require 'appium_thor' - -Appium::Thor::Config.set do - gem_name 'appium_console' - github_owner 'appium' - github_name 'ruby_console' - version_file 'lib/appium_console/version.rb' -end - -# Must use '::' otherwise Default will point to Thor::Sandbox::Default -# Debug by calling Thor::Base.subclass_files via Pry -# -# https://github.com/erikhuda/thor/issues/484 -# -class ::Default < Thor - desc 'spec', 'Run RSpec tests' - def spec - exec 'rspec spec' - end -end diff --git a/appium_console.gemspec b/appium_console.gemspec index 37e0452..10aee6c 100644 --- a/appium_console.gemspec +++ b/appium_console.gemspec @@ -29,4 +29,10 @@ Gem::Specification.new do |s| s.executables = ['arc'] s.files = `git ls-files`.split "\n" s.metadata['rubygems_mfa_required'] = 'true' + # Keep release automation out of the published package. + s.files.reject! do |file| + file.start_with?('.github/', 'script/', 'test/release/') || + %w[RELEASING.md release-please-config.json .release-please-manifest.json].include?(file) + end + end diff --git a/lib/appium_console/version.rb b/lib/appium_console/version.rb index 1ce679e..a3d90e9 100644 --- a/lib/appium_console/version.rb +++ b/lib/appium_console/version.rb @@ -5,6 +5,6 @@ module Appium; end unless defined? Appium module Appium module Console VERSION = '4.2.0' unless defined? ::Appium::Console::VERSION - DATE = '2025-01-25' unless defined? ::Appium::Console::DATE + DATE = '2025-01-25' unless defined? ::Appium::Console::DATE # x-release-please-date end end diff --git a/readme.md b/readme.md index 5c39798..8a23bae 100644 --- a/readme.md +++ b/readme.md @@ -31,3 +31,8 @@ - 3.0.0 - Update ruby_lib version to v12 + +## Releasing + +Review and merge the Release Please PR to publish. See [RELEASING.md](RELEASING.md) +for one-time Trusted Publishing setup and recovery instructions. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..beb2365 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "2f63a20264d375195d49b975df974522605c54ce", + "packages": { + ".": { + "release-type": "ruby", + "package-name": "appium_console", + "version-file": "lib/appium_console/version.rb", + "include-component-in-tag": false, + "include-v-in-tag": true, + "changelog-path": "CHANGELOG.md", + "extra-files": [ + "lib/appium_console/version.rb" + ] + } + } +} diff --git a/script/release.rb b/script/release.rb new file mode 100644 index 0000000..aaefd78 --- /dev/null +++ b/script/release.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'date' +require 'json' +require 'rubygems/package' + +# Deterministic release checks shared by release PRs and the publishing workflow. +# This script never commits, tags, or publishes anything. +module ReleaseTools + VERSION_FILE = 'lib/appium_console/version.rb' + TAG_PATTERN = /\Av\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?\z/ + + module_function + + def metadata(root) + source = File.read(File.join(root, VERSION_FILE)) + version = source.match(/VERSION\s*=\s*'([^']+)'/)&.[](1) + date = source.match(/DATE\s*=\s*'([^']+)'/)&.[](1) + raise 'Missing VERSION or DATE' unless version && date + + manifest = JSON.parse(File.read(File.join(root, '.release-please-manifest.json'))).fetch('.') + raise 'Manifest and VERSION differ' unless manifest == version + + # Accept both historical headers and Release Please's linked headers. + header = File.read(File.join(root, 'CHANGELOG.md')).lines.find { |line| line.start_with?('## ') } + match = header&.match(/^## \[([^\]]+)\](?:\([^)]*\))? (?:- |\()(\d{4}-\d{2}-\d{2})\)?\s*$/) + raise 'Latest changelog entry does not match VERSION' unless match && match[1] == version + + release_date = match[2] + Date.iso8601(release_date) + [version, date, release_date] + end + + def prepare_date(root) + _version, current_date, release_date = metadata(root) + return if current_date == release_date + + path = File.join(root, VERSION_FILE) + source = File.read(path) + File.write(path, source.sub(/(DATE\s*=\s*)'[^']+'/, "\\1'#{release_date}'")) + end + + def verify(root, tag = nil) + version, date, release_date = metadata(root) + raise 'DATE does not match the changelog; run prepare-date' unless date == release_date + + if tag + raise 'Invalid release tag' unless TAG_PATTERN.match?(tag) + raise 'Tag and VERSION differ' unless tag == "v#{version}" + end + + gem_version = Gem::Version.new(version) + gem_path = "pkg/appium_console-#{gem_version}.gem" + package = Gem::Package.new(File.join(root, gem_path)) + package.verify + spec = package.spec + raise 'Gem name or version differs from release metadata' unless spec.name == 'appium_console' && spec.version == gem_version + + packaged_source = package.contents.include?(VERSION_FILE) + raise 'Gem is missing version.rb' unless packaged_source + + gem_path + end +end + +if $PROGRAM_NAME == __FILE__ + root = File.expand_path('..', __dir__) + case ARGV.shift + when 'prepare-date' + ReleaseTools.prepare_date(root) + when 'verify' + puts "gem_path=#{ReleaseTools.verify(root, ARGV.shift)}" + else + abort 'Usage: ruby script/release.rb prepare-date|verify [tag]' + end +end diff --git a/test/release/release_test.rb b/test/release/release_test.rb new file mode 100644 index 0000000..17a998f --- /dev/null +++ b/test/release/release_test.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'minitest/autorun' +require 'tmpdir' +require_relative '../../script/release' + +class ReleaseTest < Minitest::Test + def setup + @root = Dir.mktmpdir('appium-release-test') + write_metadata + end + + def teardown + FileUtils.remove_entry(@root) + end + + def test_existing_changelog_format + build_gem + assert_equal 'pkg/appium_console-13.1.1.gem', ReleaseTools.verify(@root, 'v13.1.1') + end + + def test_release_please_date_update_is_idempotent + write_metadata(date: '2026-07-13', linked: true) + ReleaseTools.prepare_date(@root) + path = File.join(@root, ReleaseTools::VERSION_FILE) + updated = File.read(path) + assert_includes updated, "DATE = '2026-09-12'" + assert_includes updated, "VERSION = '13.1.1'" + ReleaseTools.prepare_date(@root) + assert_equal updated, File.read(path) + build_gem + assert_equal 'pkg/appium_console-13.1.1.gem', ReleaseTools.verify(@root) + end + + def test_manifest_mismatch + File.write(File.join(@root, '.release-please-manifest.json'), JSON.generate('.' => '4.2.0')) + assert_raises(RuntimeError) { ReleaseTools.verify(@root) } + end + + def test_changelog_mismatch + File.write(File.join(@root, 'CHANGELOG.md'), "## [4.2.0] - 2026-09-12\n") + assert_raises(RuntimeError) { ReleaseTools.prepare_date(@root) } + end + + def test_missing_changelog_entry + File.write(File.join(@root, 'CHANGELOG.md'), '# Changelog') + assert_raises(RuntimeError) { ReleaseTools.verify(@root) } + end + + def test_invalid_calendar_date + write_metadata(release_date: '2026-02-30') + assert_raises(Date::Error) { ReleaseTools.prepare_date(@root) } + end + + def test_stale_date_blocks_publication + write_metadata(date: '2026-07-13') + assert_raises(RuntimeError) { ReleaseTools.verify(@root) } + end + + def test_wrong_or_malformed_tag + ['v13.1.2', '13.1.1', 'master', 'v13.1.1/other', "v13.1.1\n", 'v13.1.1;command'].each do |tag| + assert_raises(RuntimeError, tag.inspect) { ReleaseTools.verify(@root, tag) } + end + end + + def test_prerelease_uses_rubygems_normalized_filename + write_metadata(version: '13.2.0-rc.1', linked: true) + build_gem(version: '13.2.0-rc.1') + assert_equal 'pkg/appium_console-13.2.0.pre.rc.1.gem', ReleaseTools.verify(@root, 'v13.2.0-rc.1') + end + + def test_wrong_gem_identity + path = build_gem(name: 'different-gem') + FileUtils.mv(path, File.join(@root, 'pkg/appium_console-13.1.1.gem')) + assert_raises(RuntimeError) { ReleaseTools.verify(@root) } + end + + def test_wrong_packaged_version + path = build_gem(version: '4.2.0') + FileUtils.mv(path, File.join(@root, 'pkg/appium_console-13.1.1.gem')) + assert_raises(RuntimeError) { ReleaseTools.verify(@root) } + end + + def test_missing_packaged_version_file + build_gem(include_version: false) + assert_raises(RuntimeError) { ReleaseTools.verify(@root) } + end + + private + + def write_metadata(version: '13.1.1', date: '2026-09-12', release_date: '2026-09-12', linked: false) + path = File.join(@root, ReleaseTools::VERSION_FILE) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "VERSION = '#{version}'\nDATE = '#{date}'\n") + File.write(File.join(@root, '.release-please-manifest.json'), JSON.generate('.' => version)) + header = linked ? "## [#{version}](https://example.com/compare) (#{release_date})" : "## [#{version}] - #{release_date}" + File.write(File.join(@root, 'CHANGELOG.md'), "# Changelog\n\n#{header}\n\n- A change\n") + end + + def build_gem(name: 'appium_console', version: '13.1.1', include_version: true) + spec = Gem::Specification.new do |gem| + gem.name = name + gem.version = version + gem.summary = 'Release validation fixture' + gem.authors = ['Test'] + gem.files = include_version ? [ReleaseTools::VERSION_FILE] : [] + gem.license = 'Apache-2.0' + gem.homepage = 'https://example.com' + end + output = File.join(@root, "pkg/#{spec.file_name}") + FileUtils.mkdir_p(File.dirname(output)) + capture_io { Dir.chdir(@root) { Gem::Package.build(spec, true, false, output) } } + output + end +end From 5481fdbc7c29c0d926ee7a0409961771de145fcf Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Sun, 13 Sep 2026 08:41:07 -0700 Subject: [PATCH 2/2] ci: use shared Ruby release validator --- .github/workflows/publish-gem.yml | 18 +++- .github/workflows/release-validation.yml | 15 ++- Gemfile | 2 - RELEASING.md | 36 +++++-- script/release.rb | 76 --------------- test/release/release_test.rb | 116 ----------------------- 6 files changed, 47 insertions(+), 216 deletions(-) delete mode 100644 script/release.rb delete mode 100644 test/release/release_test.rb diff --git a/.github/workflows/publish-gem.yml b/.github/workflows/publish-gem.yml index c80299b..5ebe916 100644 --- a/.github/workflows/publish-gem.yml +++ b/.github/workflows/publish-gem.yml @@ -23,7 +23,7 @@ jobs: if: github.repository == 'appium/ruby_console' runs-on: ubuntu-latest outputs: - gem_path: ${{ steps.package.outputs.gem_path }} + gem_filename: ${{ steps.artifact.outputs.gem-filename }} steps: - name: Validate tag syntax shell: bash @@ -50,14 +50,22 @@ jobs: run: | bundle exec rake rubocop bundle exec ruby -Ilib bin/arc version - bundle exec ruby test/release/release_test.rb - name: Validate the built gem id: package - run: ruby script/release.rb verify "$RELEASE_TAG" >> "$GITHUB_OUTPUT" + uses: appium/appium-workflows/.github/actions/ruby-release-validate@b9358466aa945baa7afa4d8f177d9fa4f4515bc0 + with: + gem-name: appium_console + version-file: lib/appium_console/version.rb + tag: ${{ env.RELEASE_TAG }} + - name: Record artifact filename for the publishing job + id: artifact + env: + VERIFIED_GEM_PATH: ${{ steps.package.outputs.gem-path }} + run: echo "gem-filename=$(basename "$VERIFIED_GEM_PATH")" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 with: name: release-gem - path: ${{ steps.package.outputs.gem_path }} + path: ${{ steps.package.outputs.gem-path }} if-no-files-found: error retention-days: 14 @@ -80,5 +88,5 @@ jobs: uses: rubygems/configure-rubygems-credentials@daab0479595bcc124493903b3df17273d86e073f - name: Publish the verified gem env: - GEM_PATH_TO_PUBLISH: ${{ needs.verify.outputs.gem_path }} + GEM_PATH_TO_PUBLISH: pkg/${{ needs.verify.outputs.gem_filename }} run: gem push --host https://rubygems.org "$GEM_PATH_TO_PUBLISH" diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml index b7c0937..672672f 100644 --- a/.github/workflows/release-validation.yml +++ b/.github/workflows/release-validation.yml @@ -20,15 +20,14 @@ jobs: with: ruby-version: '4.0' bundler-cache: true - - name: Test release validation - run: | - ruby -c script/release.rb - bundle exec ruby test/release/release_test.rb - name: Check console run: | bundle exec rake rubocop bundle exec ruby -Ilib bin/arc version - - name: Build and validate gem - run: | - bundle exec rake build - ruby script/release.rb verify + - name: Build gem + run: bundle exec rake build + - name: Validate built gem + uses: appium/appium-workflows/.github/actions/ruby-release-validate@b9358466aa945baa7afa4d8f177d9fa4f4515bc0 + with: + gem-name: appium_console + version-file: lib/appium_console/version.rb diff --git a/Gemfile b/Gemfile index 03c103b..7a34208 100644 --- a/Gemfile +++ b/Gemfile @@ -3,5 +3,3 @@ gemspec gem 'rake', '~> 13.0' gem 'rubocop', '1.90.0' - -gem 'minitest', '~> 5.0' diff --git a/RELEASING.md b/RELEASING.md index f6dfc03..2d0028c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -89,8 +89,8 @@ is for stable releases from master, not a parallel prerelease branch. ## Recovery - **Release PR has a date mismatch:** rerun **Release Please**. Alternatively, check - out its branch, run `ruby script/release.rb prepare-date`, and commit the date - change. Do not edit the date to the upload day independently of the changelog. + out its branch, use the shared validator’s `prepare-date` command described + below, and commit the date change. Do not edit the date to the upload day independently of the changelog. - **PR checks do not start:** inspect the **Run release PR checks** step, Actions policy, and workflow permissions. Each dispatched workflow must exist on master. Retry the affected workflow from the generated PR branch using **Run workflow**; @@ -101,7 +101,7 @@ is for stable releases from master, not a parallel prerelease branch. not necessarily emit an already-created release again. The verified artifact is kept for 14 days. If it has expired, run **Publish gem** manually from master with the same existing release tag; it rechecks and rebuilds that tag. This retry route is for releases - containing this workflow and validation script, not older historical tags. + containing this publishing workflow, not older historical tags. - **RubyGems already has that version:** confirm the existing release before retrying. The workflow deliberately does not treat every push failure as success. Published versions cannot be overwritten. Do not move the tag or rebuild different source @@ -116,14 +116,32 @@ is for stable releases from master, not a parallel prerelease branch. bundle install bundle exec rake rubocop bundle exec ruby -Ilib bin/arc version -bundle exec ruby test/release/release_test.rb bundle exec rake build -ruby script/release.rb verify ``` -To validate a prospective tag, pass `v` to `verify`. This checks metadata; -it does not create the tag. `prepare-date` changes only the local version file. -Neither script command pushes or publishes. `bundle exec rake build` only builds. +The CI workflows use the shared [ruby-release-validate Action](https://github.com/appium/appium-workflows/tree/b9358466aa945baa7afa4d8f177d9fa4f4515bc0/.github/actions/ruby-release-validate), +pinned to commit `b9358466aa945baa7afa4d8f177d9fa4f4515bc0`. Its validation tests are maintained +in that repository; this repository keeps its own application tests. + +For local metadata/package verification, clone the shared repository alongside +this checkout, then run the same validator (adjust paths as needed): + +```sh +git clone https://github.com/appium/appium-workflows.git ../appium-workflows +git -C ../appium-workflows checkout b9358466aa945baa7afa4d8f177d9fa4f4515bc0 +ruby ../appium-workflows/.github/actions/ruby-release-validate/release.rb \ + verify --root . --gem-name appium_console --version-file lib/appium_console/version.rb +``` + +Add `--tag v` to verify a prospective tag. For date recovery, replace +`verify` with `prepare-date` and omit `--tag`; it copies the changelog date into +the local version file. Neither command pushes or publishes. If the shared +checkout already exists, use it instead of cloning again. + +The Action returns an absolute `gem-path` for artifact upload. The workflow passes +only the filename to the separate publishing job, which downloads the artifact +into its own `pkg/` directory. The publishing workflow, `rubygems` environment, +and Trusted Publisher registration remain in this repository. The repository bootstrap is pinned to the existing `v4.2.0` commit `2f63a20264d375195d49b975df974522605c54ce`, with manifest version `4.2.0`. This avoids @@ -146,4 +164,4 @@ second tool's tag-creation/release sequence. The runtime `thor` dependency is retained for the `arc` CLI. Only the separate `appium_thor` release tooling is removed. The old `thor spec` task referred to a -nonexistent `spec/` directory; release checks now run via Minitest directly. +nonexistent `spec/` directory; release helper tests now live in the shared Action repository. diff --git a/script/release.rb b/script/release.rb deleted file mode 100644 index aaefd78..0000000 --- a/script/release.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -require 'date' -require 'json' -require 'rubygems/package' - -# Deterministic release checks shared by release PRs and the publishing workflow. -# This script never commits, tags, or publishes anything. -module ReleaseTools - VERSION_FILE = 'lib/appium_console/version.rb' - TAG_PATTERN = /\Av\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?\z/ - - module_function - - def metadata(root) - source = File.read(File.join(root, VERSION_FILE)) - version = source.match(/VERSION\s*=\s*'([^']+)'/)&.[](1) - date = source.match(/DATE\s*=\s*'([^']+)'/)&.[](1) - raise 'Missing VERSION or DATE' unless version && date - - manifest = JSON.parse(File.read(File.join(root, '.release-please-manifest.json'))).fetch('.') - raise 'Manifest and VERSION differ' unless manifest == version - - # Accept both historical headers and Release Please's linked headers. - header = File.read(File.join(root, 'CHANGELOG.md')).lines.find { |line| line.start_with?('## ') } - match = header&.match(/^## \[([^\]]+)\](?:\([^)]*\))? (?:- |\()(\d{4}-\d{2}-\d{2})\)?\s*$/) - raise 'Latest changelog entry does not match VERSION' unless match && match[1] == version - - release_date = match[2] - Date.iso8601(release_date) - [version, date, release_date] - end - - def prepare_date(root) - _version, current_date, release_date = metadata(root) - return if current_date == release_date - - path = File.join(root, VERSION_FILE) - source = File.read(path) - File.write(path, source.sub(/(DATE\s*=\s*)'[^']+'/, "\\1'#{release_date}'")) - end - - def verify(root, tag = nil) - version, date, release_date = metadata(root) - raise 'DATE does not match the changelog; run prepare-date' unless date == release_date - - if tag - raise 'Invalid release tag' unless TAG_PATTERN.match?(tag) - raise 'Tag and VERSION differ' unless tag == "v#{version}" - end - - gem_version = Gem::Version.new(version) - gem_path = "pkg/appium_console-#{gem_version}.gem" - package = Gem::Package.new(File.join(root, gem_path)) - package.verify - spec = package.spec - raise 'Gem name or version differs from release metadata' unless spec.name == 'appium_console' && spec.version == gem_version - - packaged_source = package.contents.include?(VERSION_FILE) - raise 'Gem is missing version.rb' unless packaged_source - - gem_path - end -end - -if $PROGRAM_NAME == __FILE__ - root = File.expand_path('..', __dir__) - case ARGV.shift - when 'prepare-date' - ReleaseTools.prepare_date(root) - when 'verify' - puts "gem_path=#{ReleaseTools.verify(root, ARGV.shift)}" - else - abort 'Usage: ruby script/release.rb prepare-date|verify [tag]' - end -end diff --git a/test/release/release_test.rb b/test/release/release_test.rb deleted file mode 100644 index 17a998f..0000000 --- a/test/release/release_test.rb +++ /dev/null @@ -1,116 +0,0 @@ -# frozen_string_literal: true - -require 'fileutils' -require 'minitest/autorun' -require 'tmpdir' -require_relative '../../script/release' - -class ReleaseTest < Minitest::Test - def setup - @root = Dir.mktmpdir('appium-release-test') - write_metadata - end - - def teardown - FileUtils.remove_entry(@root) - end - - def test_existing_changelog_format - build_gem - assert_equal 'pkg/appium_console-13.1.1.gem', ReleaseTools.verify(@root, 'v13.1.1') - end - - def test_release_please_date_update_is_idempotent - write_metadata(date: '2026-07-13', linked: true) - ReleaseTools.prepare_date(@root) - path = File.join(@root, ReleaseTools::VERSION_FILE) - updated = File.read(path) - assert_includes updated, "DATE = '2026-09-12'" - assert_includes updated, "VERSION = '13.1.1'" - ReleaseTools.prepare_date(@root) - assert_equal updated, File.read(path) - build_gem - assert_equal 'pkg/appium_console-13.1.1.gem', ReleaseTools.verify(@root) - end - - def test_manifest_mismatch - File.write(File.join(@root, '.release-please-manifest.json'), JSON.generate('.' => '4.2.0')) - assert_raises(RuntimeError) { ReleaseTools.verify(@root) } - end - - def test_changelog_mismatch - File.write(File.join(@root, 'CHANGELOG.md'), "## [4.2.0] - 2026-09-12\n") - assert_raises(RuntimeError) { ReleaseTools.prepare_date(@root) } - end - - def test_missing_changelog_entry - File.write(File.join(@root, 'CHANGELOG.md'), '# Changelog') - assert_raises(RuntimeError) { ReleaseTools.verify(@root) } - end - - def test_invalid_calendar_date - write_metadata(release_date: '2026-02-30') - assert_raises(Date::Error) { ReleaseTools.prepare_date(@root) } - end - - def test_stale_date_blocks_publication - write_metadata(date: '2026-07-13') - assert_raises(RuntimeError) { ReleaseTools.verify(@root) } - end - - def test_wrong_or_malformed_tag - ['v13.1.2', '13.1.1', 'master', 'v13.1.1/other', "v13.1.1\n", 'v13.1.1;command'].each do |tag| - assert_raises(RuntimeError, tag.inspect) { ReleaseTools.verify(@root, tag) } - end - end - - def test_prerelease_uses_rubygems_normalized_filename - write_metadata(version: '13.2.0-rc.1', linked: true) - build_gem(version: '13.2.0-rc.1') - assert_equal 'pkg/appium_console-13.2.0.pre.rc.1.gem', ReleaseTools.verify(@root, 'v13.2.0-rc.1') - end - - def test_wrong_gem_identity - path = build_gem(name: 'different-gem') - FileUtils.mv(path, File.join(@root, 'pkg/appium_console-13.1.1.gem')) - assert_raises(RuntimeError) { ReleaseTools.verify(@root) } - end - - def test_wrong_packaged_version - path = build_gem(version: '4.2.0') - FileUtils.mv(path, File.join(@root, 'pkg/appium_console-13.1.1.gem')) - assert_raises(RuntimeError) { ReleaseTools.verify(@root) } - end - - def test_missing_packaged_version_file - build_gem(include_version: false) - assert_raises(RuntimeError) { ReleaseTools.verify(@root) } - end - - private - - def write_metadata(version: '13.1.1', date: '2026-09-12', release_date: '2026-09-12', linked: false) - path = File.join(@root, ReleaseTools::VERSION_FILE) - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, "VERSION = '#{version}'\nDATE = '#{date}'\n") - File.write(File.join(@root, '.release-please-manifest.json'), JSON.generate('.' => version)) - header = linked ? "## [#{version}](https://example.com/compare) (#{release_date})" : "## [#{version}] - #{release_date}" - File.write(File.join(@root, 'CHANGELOG.md'), "# Changelog\n\n#{header}\n\n- A change\n") - end - - def build_gem(name: 'appium_console', version: '13.1.1', include_version: true) - spec = Gem::Specification.new do |gem| - gem.name = name - gem.version = version - gem.summary = 'Release validation fixture' - gem.authors = ['Test'] - gem.files = include_version ? [ReleaseTools::VERSION_FILE] : [] - gem.license = 'Apache-2.0' - gem.homepage = 'https://example.com' - end - output = File.join(@root, "pkg/#{spec.file_name}") - FileUtils.mkdir_p(File.dirname(output)) - capture_io { Dir.chdir(@root) { Gem::Package.build(spec, true, false, output) } } - output - end -end