diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c294609 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +--- +version: 2 +updates: +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + rebase-strategy: disabled + open-pull-requests-limit: 10 + cooldown: + default-days: 7 diff --git a/.github/workflows/check-pinned-actions.yml b/.github/workflows/check-pinned-actions.yml new file mode 100644 index 0000000..5a35d27 --- /dev/null +++ b/.github/workflows/check-pinned-actions.yml @@ -0,0 +1,11 @@ +name: Check actions have their versions pinned + +on: + push: + paths: + - '.github/workflows/*.yml' + - '.github/workflows/*.yaml' + +jobs: + pinact: + uses: fac/shared-workflows/.github/workflows/check_pinned_actions.yml@main diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..d50bda6 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,26 @@ +name: Lint Code Base +# https://help.github.com/en/articles/workflow-syntax-for-github-actions + +on: + pull_request: + push: + branches: + - main + +jobs: + linter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 # Full history to get a proper list of changed files within `super-linter` + + - name: Lint Code Base + uses: github/super-linter@985ef206aaca4d560cb9ee2af2b42ba44adc1d55 # v4.10.0 + env: + VALIDATE_ALL_CODEBASE: false + VALIDATE_BASH: true + VALIDATE_YAML: true + DEFAULT_BRANCH: main + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml new file mode 100644 index 0000000..2b71434 --- /dev/null +++ b/.github/workflows/reviewdog.yml @@ -0,0 +1,24 @@ +name: Lint workflow files + +on: + push: + paths: + - '.github/workflows/*.yml' + - '.github/workflows/*.yaml' + pull_request: + types: + - opened + - reopened + - synchronize + paths: + - '.github/workflows/*.yml' + - '.github/workflows/*.yaml' + +permissions: + pull-requests: write + contents: read + +jobs: + actionlint: + uses: fac/hermod/.github/workflows/actionlint.yml@master + secrets: inherit diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..7ec66ad --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,13 @@ +name: Shellcheck +on: [pull_request] +jobs: + shellcheck: + name: runner / shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - name: shellcheck + uses: reviewdog/action-shellcheck@4c07458293ac342d477251099501a718ae5ef86e # v1.32.0 + with: + fail_on_error: true + reporter: github-pr-review diff --git a/.pinact.yaml b/.pinact.yaml new file mode 100644 index 0000000..b5b2a7f --- /dev/null +++ b/.pinact.yaml @@ -0,0 +1,5 @@ +--- +version: 3 +ignore_actions: +- name: fac/.* + ref: "^(main|master)$" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2bd7a..fe0a4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,63 @@ # CHANGELOG +TODO: v2 changes + ## [Unreleased] +## [2.5.0] - 2022-10-18 + +- Update: Switch to the new method of setting outputs (fac/ruby-gem-push-action/pull/16) +- Update: Updated Linters to latest versions + +## [2.4.1] - 2021-07-20 + +- Fix: detect when allowed_push_host or GEM_HOST is not set + +## [2.4.0] - 2021-07-20 + +- Add: support for `ACTIONS_STEP_DEBUG` to see what the push script is doing + +## [2.3.0] - 2021-05-19 + +- Add: explicit preflight check on the push host, to work around gem failing successfully on malformed push hosts + +## [2.2.0] - 2021-04-30 + +- Fix: Bug with pre-release:false input getting ignored + +## [2.1.0] - 2021-04-30 + +- Fix: Bug with pre-release input getting ignored + +## [2.0.1] - 2021-04-26 + +- Update: README to show usage with renamed `ruby-gem-setup-credentials@v2` + +## [2.0.0] - 2021-04-26 + +- Change: Don't pass the gem host around as an environment variable, extract from the gemspec. +- Change: Don't pass gem keys around in environment variables anymore. Use the installed creds by key name. +- Add: input `key` to set the key name in gem credentials to use. +- Change: Release/pre-release inputs collapsed into single pre-release input. Push is either release or pre-release version, can't do both (or none!) in the same call anymore. +- Add: Add linter for action code. +- Change: `tag-release` input renamed to just `tag`. +- Change: Use command line args instead of env variables for the internal command. + +## [1.3.0] - 2021-04-16 + +- Fix: clean shell log handling for `gem push` call + +## [1.2.0] - 2021-04-15 + +- Change: name to ruby-gem-push-action. Old name still works due to GH redirects. + +## [1.1.0] - 2021-04-15 + +- Add: input release: Whether to push release versions +- Add: input pre-release: Whether to push pre-release versions +- Add: input tag-release: After pushing a new gem version, git tag with the version string +- Add: output pushed-version: the version of the gem pushed to the repository + ## [1.0.0] - 2021-04-15 - Add basic action that pushes gems to the repository diff --git a/README.md b/README.md index 85af67d..ae28c81 100644 --- a/README.md +++ b/README.md @@ -2,66 +2,158 @@ ## Description -Action to push gems to a gem cutter compatible repository. Probably RubyGems or GitHub Package Repository. It expects the authentication to have already been setup, using the environment variables GEM_HOST and GEM_HOST_API_KEY. See [fac/rubygems-setup-gpr-action](https://github.com/fac/rubygems-setup-gpr-action) for an action to set this up for you to push to GPR. +Action to push gems to a gem cutter compatible repository. Basically RubyGems or GitHub Packages. It expects the authentication to have already been setup, `~/.gem/credentials` contains a token for the repo and you know the name of the key. +See [fac/ruby-gem-credentials-action](https://github.com/fac/ruby-gem-setup-credentials-action) for an action to set this up for you. It is actually pretty easy if pushing to the same repo. -If the gem version already exists in the repo the action will no-op and still set a success status. This makes it easier to integrate into workflows, safe to re-run (intentionally of accidentally) and wont push duplicate/replacement packages. +If the gem version already exists in the repo the action will no-op and still set a success status. This makes it easier to integrate into workflows, safe to re-run (intentionally or accidentally) and wont push duplicate/replacement packages. +It will still raise an error visible in the summary, letting you know the version already exists. ## Usage +The action expects that you have already checked out your gem and setup your ruby environment (bundle installed), such that gem and ruby commands are available. The easiest way to do this is using `actions/checkout` and `ruby/setup-ruby` actions with a `.ruby-version` file. See example below. + +### Basic Setup + +Build and push all new version of the gem: + ```yaml steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 # .ruby-version + with: + bundler-cache: true # bundle install - - name: Build Gem - shell: bash - run: bundle exec rake build + - run: bundle exec rake build - - name: Setup GPR - uses: fac/rubygems-setup-gpr-action@v1 + - uses: fac/ruby-gem-setup-credentials-action@v2 with: token: ${{ secrets.github_token }} - - name: Push Gem - uses: fac/rubygems-push-action@v1 + - uses: fac/ruby-gem-push-action@v2 + with: + key: github ``` -If you want to use a different gem host or key: +Note that the ruby-gem-push-action will push to the host given in the gemspec. The token needs to match. Trying to push to a different host will fail. + +### Separate release and pre-release workflow + +By default, the action only acts on non-pre-release versions, and prints a message if it thinks the gem has a pre-release version number. If you set the input option `pre-release: true`, then it will only act on pre-release versions, and will skip over regular versions. That way, you can have 2 calls to the action, using the workflow to decide the logic. + +Say you want to push release versions from your default branch (main) and pre-release versions from PR builds: ```yaml - - name: Push Gem - uses: fac/rubygems-push-action@v1 - env: - gem_host: http://gems.example.com - gem_host_api_key: ${{ secrets.EXAMPLE_APYI_KEY }} +name: Gem Build and Release +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: Gem / Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + - name: Test + run: bundle exec rake + + release: + name: Gem / Release + needs: test # Only release IF the tests pass + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + - uses: fac/ruby-gem-setup-credentials-action@v2 + with: + token: ${{ secrets.github_token }} + + - name: Build Gem + run: bundle exec rake build + + # Release production gem version from default branch + - name: Release Gem + if: github.ref == 'refs/heads/main' + uses: fac/ruby-gem-push-action@v2 + with: + key: github + + # PR branch builds will release pre-release gems + - name: Pre-Release Gem + if: github.ref != 'refs/heads/main' + uses: fac/ruby-gem-push-action@v2 + with: + key: github + pre-release: true ``` +Here we run the test on its own job, so that it gets it's own status on the PR, that you can require separately from the release job in your branch protection. +The release job runs if the tests pass, we always package the gem to test that works. For release we use a conditional along with the actions inputs to push release versions for the main branch only and push pre-releases for PR. + +### Debugging + +This action supports [debug logging](https://docs.github.com/en/actions/managing-workflow-runs/enabling-debug-logging#enabling-step-debug-logging). Enable it by setting the `ACTIONS_STEP_DEBUG` secret to `true` on your repository. + ## Inputs -### package-glob +### working-directory +Sets the working directory before all other steps occur. This is useful for cases where files like `.ruby-version` and `Gemfile` aren't located in the root directory. For example in a monorepo where the Ruby project is located in its own subfolder. + +```yaml + name: Push Gem + uses: fac/ruby-gem-push-action@v2 + with: + working-directory: './ruby_project/' +``` + +### gem-glob File glob to match the gem file to push. The default `pkg/*.gem` picks up gems built using `bundle exec rake build`. You may need to set this if your your gem builds another way. +_Note_: `working-directory` is set before this step, therefore if both inputs are provided, this path will be relative to the `working-directory`. ```yaml - name: Push Gem - uses: fac/rubygems-push-action@v1 + uses: fac/ruby-gem-push-action@v2 with: - package-glob: build/special.gem + gem-glob: build/special.gem ``` +### pre-release + +Whether to push new pre-release versions of the gem and ignore releases, instead of the normal, push prod releases but ignore pre-release. + +### tag + +When true (the default), after pushing a new gem version tag the repo with +the version number prefixed with `v`. e.g. after pushing version `0.1.0`, the +tag will be `v0.1.0`. This is the same behavior as `gem tag`. (Internally +implemented to work with older gem versions and around bugs that caused tags for failed pushes, which then blocked re-pushing. + +The tag commit and push will be made as the author of the commit being tagged. ## Outputs -None. +### pushed-version + +If we pushed a gem to the repository, this will be set to the version pushed. ## Environment Variables -### GEM_HOST_API_KEY +None. -Read to get the API key string (prefixed token with Bearer), to access the package repo. Used by `gem push`. +## Troubleshooting -### GEM_HOST +If when tagging the action fails with `shallow update not allowed` try setting `fetch-depth` to 0, i.e. dont' do a shallow clone. + +```yml +- uses: actions/checkout@v2 + with: + fetch-depth: 0 +``` -The host URL for pushing gems to. ## Authors diff --git a/action.yml b/action.yml index 3ff1163..d6116a3 100644 --- a/action.yml +++ b/action.yml @@ -1,29 +1,42 @@ # See: https://docs.github.com/en/actions/creating-actions name: Gem Push author: FreeAgent -description: Push gem packages to a rubygems compatible repo +branding: + icon: 'globe' + color: 'red' +description: Push gem packages to a rubygems compatible repository inputs: - package-glob: - description: "File glob to match the .gem files to push" + key: + description: "Name of credentials key to use from ~/.gem/credentials." + default: "" + gem-glob: + description: File glob to match the .gem files to push default: "pkg/*.gem" + pre-release: + description: Whether to push pre-release versions, instead of release versions (the default). + default: false + tag: + description: After pushing a new gem version, git tag with the version string + default: true + working-directory: + description: "The working directory of the ruby project. Useful for cases where files like .ruby-version and Gemfile aren't located in the root directory." + required: false + default: "." +outputs: + pushed-version: + description: "The version of the gem pushed to the repository" + value: ${{ steps.push-gem.outputs.pushed-version }} runs: using: "composite" steps: - name: Push Gem + id: push-gem shell: bash - env: - # Expects GEM_HOST and GEM_HOST_API_KEY to be set - GEM_GLOB: ${{ inputs.package-glob }} run: | - if ! gem push --host "$GEM_HOST" $GEM_GLOB | tee push.out; then - gemerr=$? - if grep "has already been pushed" push.out; then - echo Gem Already Pushed - exit 0 - fi - echo ::error::Gem Push Failed - cat push.out | sed 's/^/::error::/' - exit $gemerr - fi - exit 0 + PATH="${{ github.action_path }}:$PATH" + args="" + [ '${{ inputs.pre-release }}' == true ] && args="$args -p" + [ '${{ inputs.tag }}' == true ] && args="$args -t" + [ ! -z ${{ inputs.working-directory }} ] && cd ${{ inputs.working-directory }} + gem-push-action.sh -k "${{inputs.key}}" $args ${{inputs.gem-glob}} diff --git a/gem-push-action.sh b/gem-push-action.sh new file mode 100755 index 0000000..740ce83 --- /dev/null +++ b/gem-push-action.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -e -o pipefail + +exec 19> >( sed -e's/^/::debug::/g' ) +BASH_XTRACEFD=19 +set -x + +KEY="" +PRE_RELEASE=false +TAG_RELEASE=false + +usage() { + echo "Usage: $0 [-k KEY] [-p] GEMFILE" + echo + echo Options: + echo " GEMFILE The pre-built .gem pkg you want to push" + echo " -k KEY Set the gem host credentials key name. Default: '$KEY'" + echo " -p Do a pre-release, ignore otherwise. Default: $PRE_RELEASE" + echo " -t After pushing a new version, git tag the current ref. Default: $TAG_RELEASE" + echo " -h Show this help" + exit 0 +} + +while getopts ":hk:pt" opt; do + case ${opt} in + h ) usage + ;; + k ) KEY=$OPTARG + ;; + p ) PRE_RELEASE=true + ;; + t ) TAG_RELEASE=true + ;; + \? ) usage + ;; + esac +done +shift $((OPTIND -1)) + +GEM_FILE="$1" + +# By default read the gem host from the gemspec, if they dont match the push +# will fail! Allow override if GEM_HOST is already exported. +push_host="$(parse-gemspec --push-host)" +GEM_HOST="${GEM_HOST:-$push_host}" + +if [ -z "$GEM_HOST" ] +then + echo "::error::Push host is missing! Set \`spec.metadata['allowed_push_host']\` in your gemspec" + exit 1 +fi + +# test GEM_HOST, gem silently fails with no error if the GEM_HOST redirects +# see https://github.com/rubygems/rubygems/issues/4458 +test_response_code=$(curl --silent --output /dev/null --write-out "%{http_code}" --request POST "$GEM_HOST/api/v1/gems") +if [[ $test_response_code != 401 ]] # expecting an 'authentication required' response +then + echo "::error::Push host looks malformed! Got response of $test_response_code when requesting $GEM_HOST/api/vi/gems" >&2 + echo "::error::Check for HTTPS scheme & no trailing slashes on your allowed push host ($push_host)" >&2 + exit 1 +fi + +if parse-gemspec --is-pre-release; then + if [[ $PRE_RELEASE != true ]]; then + echo "Ignoring pre-release. To release, pass pre-release: true as an input" + exit 0 + fi +else # normal release + if [[ $PRE_RELEASE == true ]]; then + echo "Ignoring release. To release, pass pre-release: false as an input" + exit 0 + fi +fi + +# Capture as we can't tell why gem push failed from the exit code and it logs +# everything to stdout, so need to grep the output. Gem existing is ok, other +# errors not. Avoids playing games setting up auth differently for gem query. +if ! gem push --key="$KEY" --host "$GEM_HOST" "$GEM_FILE" >push.out; then + gemerr=$? + sed 's/^Error:/::error::/' push.out + if grep -q "has already been pushed" push.out; then + exit 0 + fi + exit $gemerr +fi + +echo "pushed-version=$(parse-gemspec --version)" >> "$GITHUB_OUTPUT" + +if [[ $TAG_RELEASE == true ]]; then + tagname="v$( parse-gemspec --version )" + git config user.name "$(git log -1 --pretty=format:%an)" + git config user.email "$(git log -1 --pretty=format:%ae)" + git tag -a -m "Gem release $tagname" "$tagname" + git push origin "$tagname" +fi + +exit 0 diff --git a/parse-gemspec b/parse-gemspec new file mode 100755 index 0000000..29878c1 --- /dev/null +++ b/parse-gemspec @@ -0,0 +1,40 @@ +#!/usr/bin/env ruby + +require 'optparse' + +gemspecs = Dir["*.gemspec"] + +if gemspecs.empty? + warn "No gemspec found in #{Dir.pwd}" + exit 10 +end + +if gemspecs.count > 1 + warn "More than one gemspec found in #{Dir.pwd}" + exit 10 +end + +spec = Gem::Specification.load(gemspecs.first) +exit 10 unless spec + +def puts!(msg) + puts msg + exit +end + +OptionParser.new do |opts| + opts.banner = "Usage: #{File.basename($0)} [options]" + opts.on("-h", "--help", "Prints this help") do puts! opts end + + opts.on("--name", "Output name") do |v| puts! spec.name end + opts.on("--version", "Output gem version") do |v| puts! spec.version end + opts.on("--metadata", "Output metadata") do |v| puts! spec.metadata end + opts.on("--push-host", "Output metadata.allowed_push_host") do |v| + puts! spec.metadata.dig "allowed_push_host" + end + + opts.on("--is-pre-release", "Exit 0 if pre-release, 1 otherwise") do |v| + exit 0 if spec.version.prerelease? + exit 1 + end +end.parse!