|
| 1 | +name: Release gems |
| 2 | + |
| 3 | +# Builds, publishes, and announces a release. Dispatch it with the commit being |
| 4 | +# released and the version that commit declares. |
| 5 | +# |
| 6 | +# Everything is built from `commit`, not from wherever the default branch happens to |
| 7 | +# be when the run starts, so the release describes a state that is already immutable. |
| 8 | +# `version` is the same fact stated a second time -- the run stops before anything is |
| 9 | +# built unless it matches the `RBS::VERSION` of that commit, so dispatching the wrong |
| 10 | +# commit, or the right one under the wrong name, is a failed run rather than a gem |
| 11 | +# that has to be yanked. |
| 12 | +# |
| 13 | +# `dry_run` builds and checks both gems and stops before the tag, which is how the |
| 14 | +# build is exercised without releasing anything. |
| 15 | +# |
| 16 | +# | Gem | Platform | Parser | |
| 17 | +# | -------------------- | ------------- | -------------------------------- | |
| 18 | +# | `rbs-X.Y.Z.gem` | `ruby` (MRI) | C extension, compiled on install | |
| 19 | +# | `rbs-X.Y.Z-java.gem` | `java` (JRuby)| `rbs_parser.wasm`, prebuilt here | |
| 20 | +# |
| 21 | +# See docs/release.md. The `java` gem is built on CRuby: the platform comes from |
| 22 | +# `RBS_PLATFORM`, not from the engine running `gem build`. JRuby is only needed to |
| 23 | +# run the result, which the workflow does before it would publish anything. |
| 24 | +# |
| 25 | +# One job on purpose. Tagging, pushing the gems, and opening the GitHub release |
| 26 | +# all belong to a single release, and keeping them in one place keeps their order |
| 27 | +# readable -- the tag is created once both gems are known to build and run, and |
| 28 | +# before anything is published, so the reversible step comes before the irreversible |
| 29 | +# one. |
| 30 | +# |
| 31 | +# The file name is what the RubyGems trusted publisher for `rbs` is registered |
| 32 | +# against, so it cannot be renamed without registering the new name first. |
| 33 | + |
| 34 | +on: |
| 35 | + workflow_dispatch: |
| 36 | + inputs: |
| 37 | + commit: |
| 38 | + description: "Commit to release, as a full 40-character SHA" |
| 39 | + required: true |
| 40 | + version: |
| 41 | + description: "Version to release, without the leading `v` (e.g. `4.1.2`)" |
| 42 | + required: true |
| 43 | + dry_run: |
| 44 | + description: "Build and check the gems without tagging or publishing anything" |
| 45 | + type: boolean |
| 46 | + default: false |
| 47 | + |
| 48 | +permissions: |
| 49 | + contents: read |
| 50 | + |
| 51 | +env: |
| 52 | + # Keep in sync with .github/workflows/wasm.yml and .github/workflows/jruby.yml. |
| 53 | + WASI_SDK_VERSION: "33" |
| 54 | + WASI_SDK_RELEASE: "33.0" |
| 55 | + |
| 56 | +jobs: |
| 57 | + release: |
| 58 | + name: release |
| 59 | + runs-on: ubuntu-latest |
| 60 | + permissions: |
| 61 | + contents: write # push the tag, publish the GitHub release |
| 62 | + id-token: write # trusted publishing to RubyGems |
| 63 | + env: |
| 64 | + # The inputs are read through the environment rather than interpolated into |
| 65 | + # the scripts below. |
| 66 | + COMMIT: ${{ inputs.commit }} |
| 67 | + VERSION: ${{ inputs.version }} |
| 68 | + TAG: v${{ inputs.version }} |
| 69 | + steps: |
| 70 | + # The gemspec takes its file list from `git ls-files`, so both gems are built |
| 71 | + # from the committed state -- of the dispatched commit, since that is what is |
| 72 | + # checked out. The full history is needed to tell which branches contain it. |
| 73 | + - uses: actions/checkout@v7 |
| 74 | + with: |
| 75 | + ref: ${{ inputs.commit }} |
| 76 | + fetch-depth: 0 |
| 77 | + |
| 78 | + # Before anything is installed or built: these are the two things the release |
| 79 | + # is named after and built from, and a mistake in either is cheapest to catch |
| 80 | + # here. |
| 81 | + - name: Check the inputs |
| 82 | + run: | |
| 83 | + if [[ ! "$COMMIT" =~ ^[0-9a-f]{40}$ ]]; then |
| 84 | + echo "::error::\`$COMMIT\` is not a full 40-character SHA. A release names one exact commit." |
| 85 | + exit 1 |
| 86 | + fi |
| 87 | + if [[ ! "$VERSION" =~ ^[0-9][0-9a-zA-Z.]*$ ]]; then |
| 88 | + echo "::error::\`$VERSION\` is not a version number. Pass it without the leading \`v\`." |
| 89 | + exit 1 |
| 90 | + fi |
| 91 | +
|
| 92 | + # A release proper is cut from the default branch, while a patch release can |
| 93 | + # be cut from a release branch, so which branch the commit is on is not this |
| 94 | + # workflow's business. That it is on one is: a commit no branch contains is |
| 95 | + # one that nothing in the repository leads to any more. |
| 96 | + git fetch --no-tags origin "+refs/heads/*:refs/remotes/origin/*" |
| 97 | + branches=$(git branch --remotes --contains "$COMMIT" --format "%(refname:lstrip=3)") |
| 98 | + if [ -z "$branches" ]; then |
| 99 | + echo "::error::$COMMIT is not on any branch." |
| 100 | + exit 1 |
| 101 | + fi |
| 102 | + echo "Branches containing $COMMIT:" |
| 103 | + printf '%s\n' "$branches" |
| 104 | +
|
| 105 | + # A tag that already exists is a version that has already been released, and |
| 106 | + # pushing it would fail after the build rather than before it. |
| 107 | + - name: Check that the tag does not exist |
| 108 | + if: ${{ !inputs.dry_run }} |
| 109 | + run: | |
| 110 | + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null; then |
| 111 | + echo "::error::$TAG already exists, so $VERSION has been released." |
| 112 | + exit 1 |
| 113 | + fi |
| 114 | +
|
| 115 | + - name: Set up Ruby |
| 116 | + uses: ruby/setup-ruby@v1 |
| 117 | + with: |
| 118 | + ruby-version: ruby |
| 119 | + bundler: none |
| 120 | + - name: Update rubygems & bundler |
| 121 | + run: gem update --system |
| 122 | + - name: Install gems |
| 123 | + run: | |
| 124 | + bundle config set --local without libs:profilers |
| 125 | + bundle install --jobs 4 --retry 3 |
| 126 | +
|
| 127 | + # Fails before a minute is spent on the build, and before anything is pushed: |
| 128 | + # the version has to be the one the released commit declares, and -- unless |
| 129 | + # this is a `.dev.N` release -- the one CHANGELOG.md is written up for. |
| 130 | + - name: Check the version and the changelog |
| 131 | + run: bundle exec rake "gem:check_release[$VERSION]" |
| 132 | + |
| 133 | + - name: Build the ruby gem |
| 134 | + run: | |
| 135 | + mkdir -p pkg |
| 136 | + gem build rbs.gemspec -o "pkg/rbs-$VERSION.gem" |
| 137 | +
|
| 138 | + # `rake wasm:jruby_setup` compiles src/**/*.c to WebAssembly and copies the |
| 139 | + # result to lib/rbs/wasm/, where the gemspec picks it up. clang runs as a |
| 140 | + # subprocess, so this works on CRuby. |
| 141 | + - name: Install the WASI SDK |
| 142 | + run: | |
| 143 | + url="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_RELEASE}-x86_64-linux.tar.gz" |
| 144 | + mkdir -p "$HOME/wasi-sdk" |
| 145 | + curl -sSL "$url" | tar xz --strip-components=1 -C "$HOME/wasi-sdk" |
| 146 | + echo "WASI_SDK_PATH=$HOME/wasi-sdk" >> "$GITHUB_ENV" |
| 147 | + - name: Build rbs_parser.wasm |
| 148 | + run: bundle exec rake wasm:jruby_setup |
| 149 | + |
| 150 | + - name: Build the java gem |
| 151 | + env: |
| 152 | + RBS_PLATFORM: java |
| 153 | + run: gem build rbs.gemspec -o "pkg/rbs-$VERSION-java.gem" |
| 154 | + |
| 155 | + # `git ls-files` vouches for everything else, but rbs_parser.wasm is a build |
| 156 | + # artifact, so the java gem is the one that can come out quietly wrong. |
| 157 | + - name: Check the built gems |
| 158 | + run: | |
| 159 | + ruby -rrubygems/package -e ' |
| 160 | + ruby_gem, java_gem = ARGV.map { Gem::Package.new(_1).spec } |
| 161 | +
|
| 162 | + raise "unexpected platform: #{ruby_gem.platform}" unless ruby_gem.platform.to_s == "ruby" |
| 163 | + raise "the C extension is not declared" if ruby_gem.extensions.empty? |
| 164 | +
|
| 165 | + raise "unexpected platform: #{java_gem.platform}" unless java_gem.platform.to_s == "java" |
| 166 | + raise "rbs_parser.wasm is missing" unless java_gem.files.include?("lib/rbs/wasm/rbs_parser.wasm") |
| 167 | + raise "the java gem must not declare an extension" unless java_gem.extensions.empty? |
| 168 | +
|
| 169 | + [ruby_gem, java_gem].each { puts "#{_1.full_name}: #{_1.files.size} files" } |
| 170 | + ' "pkg/rbs-$VERSION.gem" "pkg/rbs-$VERSION-java.gem" |
| 171 | +
|
| 172 | + # The checks above cannot tell whether rbs_parser.wasm actually runs. Install |
| 173 | + # the gem the way a user would -- jar-dependencies fetches Chicory and ASM |
| 174 | + # from Maven during the install -- and parse something with it, so the |
| 175 | + # WebAssembly runtime is exercised end to end before anything is published. |
| 176 | + - name: Set up JRuby |
| 177 | + uses: ruby/setup-ruby@v1 |
| 178 | + with: |
| 179 | + ruby-version: jruby |
| 180 | + bundler: none |
| 181 | + - name: Check the java gem on JRuby |
| 182 | + run: | |
| 183 | + gem install "pkg/rbs-$VERSION-java.gem" |
| 184 | + ruby -e ' |
| 185 | + require "rbs" |
| 186 | + _, _, decls = RBS::Parser.parse_signature("class Foo end") |
| 187 | + names = decls.map { _1.name.to_s } |
| 188 | + raise "parsed #{names.inspect}, expected [\"Foo\"]" unless names == ["Foo"] |
| 189 | + puts "#{RUBY_ENGINE} #{RUBY_VERSION}: rbs #{RBS::VERSION} parses through the WebAssembly runtime" |
| 190 | + ' |
| 191 | +
|
| 192 | + - name: Switch back to CRuby |
| 193 | + uses: ruby/setup-ruby@v1 |
| 194 | + with: |
| 195 | + ruby-version: ruby |
| 196 | + bundler: none |
| 197 | + |
| 198 | + # Uploaded before publishing, so a failed push still leaves the gems behind. |
| 199 | + # This is also where a dry run ends. |
| 200 | + - uses: actions/upload-artifact@v7 |
| 201 | + with: |
| 202 | + name: gems |
| 203 | + path: pkg/*.gem |
| 204 | + if-no-files-found: error |
| 205 | + |
| 206 | + # Everything below runs only for a real release. |
| 207 | + |
| 208 | + # The tag comes after the gems are known to build and run, and before anything |
| 209 | + # is published: a tag can be deleted, while a version pushed to RubyGems can |
| 210 | + # only be yanked. What it names was decided by the checkout rather than by the |
| 211 | + # tagging, so nothing rests on it being created first. |
| 212 | + - name: Tag the release |
| 213 | + if: ${{ !inputs.dry_run }} |
| 214 | + run: | |
| 215 | + git config user.name "github-actions[bot]" |
| 216 | + git config user.email "github-actions[bot]@users.noreply.github.com" |
| 217 | + bundle exec rake gem:tag |
| 218 | +
|
| 219 | + - name: Configure RubyGems credentials |
| 220 | + if: ${{ !inputs.dry_run }} |
| 221 | + # No floating major tag on this action, so the exact release is pinned. |
| 222 | + uses: rubygems/configure-rubygems-credentials@v2.1.0 |
| 223 | + |
| 224 | + - name: Push the gems |
| 225 | + if: ${{ !inputs.dry_run }} |
| 226 | + run: | |
| 227 | + gem push "pkg/rbs-$VERSION.gem" |
| 228 | + gem push "pkg/rbs-$VERSION-java.gem" |
| 229 | +
|
| 230 | + # Last, so that a failed push never announces a release that has no gems. |
| 231 | + - name: Publish the GitHub release |
| 232 | + if: ${{ !inputs.dry_run }} |
| 233 | + env: |
| 234 | + GH_TOKEN: ${{ github.token }} |
| 235 | + run: bundle exec rake gem:gh_release |
0 commit comments