diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9002ebf --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,61 @@ +--- +name: Release action + +"on": + push: + branches: [main] + +permissions: + contents: write + +jobs: + checks: + name: Checks + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Code checkout + uses: actions/checkout@v6.0.3 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1.313.0 + with: + ruby-version: "3.3" + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install --yes shellcheck + + - name: Install actionlint + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + echo "${HOME}/go/bin" >> "${GITHUB_PATH}" + + - name: Run checks + run: bash script/check + + release: + name: Release + needs: checks + runs-on: ubuntu-latest + steps: + - name: Code checkout + uses: actions/checkout@v6.0.3 + with: + fetch-depth: 0 + + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + dry_run: false + + - name: Create a GitHub release + uses: ncipollo/release-action@v1.21.0 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: Release ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..eec8d26 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,34 @@ +--- +name: Pull Request + +"on": + pull_request: + +permissions: + contents: read + +jobs: + checks: + name: Checks + runs-on: ubuntu-latest + steps: + - name: Code checkout + uses: actions/checkout@v6.0.3 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1.313.0 + with: + ruby-version: "3.3" + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install --yes shellcheck + + - name: Install actionlint + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + echo "${HOME}/go/bin" >> "${GITHUB_PATH}" + + - name: Run checks + run: bash script/check diff --git a/README.md b/README.md index 30718dc..a66ffc2 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,28 @@ -# publish-gem-to-github +# publish-ruby-gem -Release your Ruby Gem to GitHub Packages, RubyGems.org, and Gem.coop with ease. This action is a fast, lightweight composite action that defaults to **Ruby 4.0**. +Release Ruby gems to GitHub Packages, RubyGems.org, Gem.coop, Forgejo, and Gitea from a single fast composite action. -## Why use this? - -- **Fast:** Composite action runs directly on the host, avoiding Docker overhead. -- **Multi-Registry:** Support for GitHub Packages, RubyGems.org, and Gem.coop. -- **Simple:** Just add your API keys to enable pushing to each registry. -- **Modern:** Defaults to Ruby 4.0 but supports any version via input. +This is the preferred Actionshub path for publishing Ruby gems. It runs directly on the GitHub runner, masks supplied tokens, fails when build or push commands fail, and writes a short publish summary to the job summary. ## Usage ```yaml +name: release + +on: + push: + branches: [main] + +permissions: + contents: read + packages: write + jobs: release: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.3 - name: Build and publish to multiple registries uses: actionshub/publish-ruby-gem@main @@ -25,19 +30,89 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} rubygems_token: ${{ secrets.RUBYGEMS_AUTH_TOKEN }} gemcoop_token: ${{ secrets.GEMCOOP_AUTH_TOKEN }} + forgejo_token: ${{ secrets.FORGEJO_TOKEN }} + forgejo_url: https://forgejo.example.com + gitea_token: ${{ secrets.GITEA_TOKEN }} + gitea_url: https://gitea.example.com +``` + +Only provide tokens for the registries you want to publish to. + +### RubyGems.org only + +```yaml +- name: Build and publish to RubyGems.org + uses: actionshub/publish-ruby-gem@main + with: + rubygems_token: ${{ secrets.RUBYGEMS_AUTH_TOKEN }} +``` + +### GitHub Packages only + +```yaml +permissions: + contents: read + packages: write + +steps: + - name: Build and publish to GitHub Packages + uses: actionshub/publish-ruby-gem@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} ``` +### Forgejo only + +```yaml +- name: Build and publish to Forgejo + uses: actionshub/publish-ruby-gem@main + with: + forgejo_token: ${{ secrets.FORGEJO_TOKEN }} + forgejo_url: https://forgejo.example.com + forgejo_owner: my-org +``` + +Forgejo publishes to `https://forgejo.example.com/api/packages/{owner}/rubygems`. + +### Gitea only + +```yaml +- name: Build and publish to Gitea + uses: actionshub/publish-ruby-gem@main + with: + gitea_token: ${{ secrets.GITEA_TOKEN }} + gitea_url: https://gitea.example.com + gitea_owner: my-org +``` + +Gitea publishes to `https://gitea.example.com/api/packages/{owner}/rubygems`. + ## Inputs | Input | Description | Default | |-------|-------------|---------| -| `github_token` | Token for GitHub package registry. | N/A | +| `github_token` | Token for GitHub Packages. | N/A | | `rubygems_token` | API key for RubyGems.org. | N/A | | `gemcoop_token` | API key for Gem.coop. | N/A | +| `forgejo_token` | Personal access token for a Forgejo RubyGems package registry. | N/A | +| `forgejo_url` | Base URL for the Forgejo instance. Required when `forgejo_token` is set. | N/A | +| `forgejo_owner` | User or organization account for the Forgejo package. | `owner`, then `${GITHUB_REPOSITORY_OWNER}` | +| `gitea_token` | Personal access token for a Gitea RubyGems package registry. | N/A | +| `gitea_url` | Base URL for the Gitea instance. Required when `gitea_token` is set. | N/A | +| `gitea_owner` | User or organization account for the Gitea package. | `owner`, then `${GITHUB_REPOSITORY_OWNER}` | | `token` | Alias for `github_token`. | N/A | -| `owner` | Name of the user or organization account for GitHub Packages. | `${GITHUB_REPOSITORY_OWNER}` | -| `working-directory` | Path to switch to before building the Gem. | `.` | -| `ruby-version` | Ruby version to use for build/push. | `4.0` | +| `owner` | User or organization account for GitHub Packages. | `${GITHUB_REPOSITORY_OWNER}` | +| `working-directory` | Path to switch to before building the gem. | `.` | +| `ruby-version` | Ruby version to use for build and push. | `4.0` | + +## Behavior + +- At least one registry token is required. +- Forgejo and Gitea require their matching `*_url` input when their token is set. +- The action builds top-level `*.gemspec` files in the working directory. +- Only gems built during the current action run are pushed. +- Failed builds and failed pushes stop the action with a GitHub Actions error annotation. +- Token values are masked before any publish work starts. ## Credits diff --git a/action.yml b/action.yml index ce7a358..d3612cd 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ -name: "Publish Gem to GitHub Packages" -description: "Builds and publishes Ruby gems to GitHub Packages" +name: "Publish Ruby Gem" +description: "Builds and publishes Ruby gems to GitHub Packages, RubyGems.org, Gem.coop, Forgejo, and Gitea" author: "Dan Webb" branding: color: green @@ -17,6 +17,24 @@ inputs: gemcoop_token: description: "API key for gem.coop." required: false + forgejo_token: + description: "Personal access token for a Forgejo RubyGems package registry." + required: false + forgejo_url: + description: "Base URL for the Forgejo instance, for example https://forgejo.example.com." + required: false + forgejo_owner: + description: "User or organization account that owns the Forgejo package. Defaults to owner or GITHUB_REPOSITORY_OWNER." + required: false + gitea_token: + description: "Personal access token for a Gitea RubyGems package registry." + required: false + gitea_url: + description: "Base URL for the Gitea instance, for example https://gitea.example.com." + required: false + gitea_owner: + description: "User or organization account that owns the Gitea package. Defaults to owner or GITHUB_REPOSITORY_OWNER." + required: false owner: description: "Name of the user or organization account that owns the repository containing your project. Defaults to $GITHUB_REPOSITORY_OWNER" required: false @@ -33,78 +51,21 @@ runs: using: "composite" steps: - name: Set up Ruby - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@v1.313.0 with: ruby-version: ${{ inputs.ruby-version }} - shell: bash working-directory: ${{ inputs.working-directory }} - run: | - git config --global --add safe.directory "${GITHUB_WORKSPACE}" - - # Collect tokens - GITHUB_TOKEN="${{ inputs.github_token || inputs.token }}" - RUBYGEMS_TOKEN="${{ inputs.rubygems_token }}" - GEMCOOP_TOKEN="${{ inputs.gemcoop_token }}" - - # Check if at least one token is provided - if [ -z "$GITHUB_TOKEN" ] && [ -z "$RUBYGEMS_TOKEN" ] && [ -z "$GEMCOOP_TOKEN" ]; then - echo "Error: No API keys found. Please provide at least one of: token, github_token, rubygems_token, or gemcoop_token." - exit 2 - fi - - # Determine owner for GitHub Packages - OWNER="${{ inputs.owner }}" - if [ -z "$OWNER" ]; then - OWNER="${GITHUB_REPOSITORY_OWNER}" - fi - - echo "Setting up access to RubyGems" - mkdir -p ~/.gem - touch ~/.gem/credentials - chmod 600 ~/.gem/credentials - - # Build credentials file - # We start with an empty file and append each configured host - : > ~/.gem/credentials - - if [ -n "$RUBYGEMS_TOKEN" ]; then - echo "Configuring RubyGems.org" - echo ":rubygems_api_key: ${RUBYGEMS_TOKEN}" >> ~/.gem/credentials - fi - - if [ -n "$GITHUB_TOKEN" ]; then - echo "Configuring GitHub Packages" - echo ":github: Bearer ${GITHUB_TOKEN}" >> ~/.gem/credentials - fi - - if [ -n "$GEMCOOP_TOKEN" ]; then - echo "Configuring Gem.coop" - # Assuming standard token usage for gem.coop - echo ":gemcoop: ${GEMCOOP_TOKEN}" >> ~/.gem/credentials - fi - - echo "Building gem(s)" - find . -name '*.gemspec' -maxdepth 1 -exec gem build {} \; - - # Push to each configured registry - for gem in *.gem; do - if [ -f "$gem" ]; then - - if [ -n "$RUBYGEMS_TOKEN" ]; then - echo "Pushing $gem to RubyGems.org" - gem push "$gem" - fi - - if [ -n "$GITHUB_TOKEN" ]; then - echo "Pushing $gem to GitHub Packages host https://rubygems.pkg.github.com/${OWNER}" - gem push "$gem" --key "github" --host "https://rubygems.pkg.github.com/${OWNER}" - fi - - if [ -n "$GEMCOOP_TOKEN" ]; then - echo "Pushing $gem to Gem.coop" - gem push "$gem" --key "gemcoop" --host "https://gem.coop" - fi - - fi - done + env: + PUBLISH_GEM_GITHUB_TOKEN: ${{ inputs.github_token || inputs.token }} + PUBLISH_GEM_RUBYGEMS_TOKEN: ${{ inputs.rubygems_token }} + PUBLISH_GEM_GEMCOOP_TOKEN: ${{ inputs.gemcoop_token }} + PUBLISH_GEM_OWNER: ${{ inputs.owner }} + PUBLISH_GEM_FORGEJO_TOKEN: ${{ inputs.forgejo_token }} + PUBLISH_GEM_FORGEJO_URL: ${{ inputs.forgejo_url }} + PUBLISH_GEM_FORGEJO_OWNER: ${{ inputs.forgejo_owner }} + PUBLISH_GEM_GITEA_TOKEN: ${{ inputs.gitea_token }} + PUBLISH_GEM_GITEA_URL: ${{ inputs.gitea_url }} + PUBLISH_GEM_GITEA_OWNER: ${{ inputs.gitea_owner }} + run: "${GITHUB_ACTION_PATH}/script/publish" diff --git a/script/check b/script/check new file mode 100755 index 0000000..744538e --- /dev/null +++ b/script/check @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +shell_scripts=(script/publish test/publish_test.sh script/check) + +bash -n "${shell_scripts[@]}" +shellcheck "${shell_scripts[@]}" +ruby -e 'require "yaml"; YAML.load_file("action.yml")' +actionlint +bash test/publish_test.sh diff --git a/script/publish b/script/publish new file mode 100755 index 0000000..c257501 --- /dev/null +++ b/script/publish @@ -0,0 +1,368 @@ +#!/usr/bin/env bash + +set -euo pipefail +shopt -s nullglob + +GITHUB_TOKEN="${PUBLISH_GEM_GITHUB_TOKEN:-}" +RUBYGEMS_TOKEN="${PUBLISH_GEM_RUBYGEMS_TOKEN:-}" +GEMCOOP_TOKEN="${PUBLISH_GEM_GEMCOOP_TOKEN:-}" +FORGEJO_TOKEN="${PUBLISH_GEM_FORGEJO_TOKEN:-}" +FORGEJO_URL="${PUBLISH_GEM_FORGEJO_URL:-}" +FORGEJO_OWNER="${PUBLISH_GEM_FORGEJO_OWNER:-}" +FORGEJO_HOST="" +GITEA_TOKEN="${PUBLISH_GEM_GITEA_TOKEN:-}" +GITEA_URL="${PUBLISH_GEM_GITEA_URL:-}" +GITEA_OWNER="${PUBLISH_GEM_GITEA_OWNER:-}" +GITEA_HOST="" +OWNER="${PUBLISH_GEM_OWNER:-}" +BUILD_DIR="" +BUILT_GEMS=() +REGISTRIES=() +GEM_FULL_NAME="" + +function workflow_escape() { + local value="${1}" + value="${value//'%'/'%25'}" + value="${value//$'\r'/'%0D'}" + value="${value//$'\n'/'%0A'}" + echo "${value}" +} + +function workflow_error() { + echo "::error::$(workflow_escape "${1}")" +} + +function fail() { + local exit_code="${1}" + local message="${2}" + + workflow_error "${message}" + exit "${exit_code}" +} + +function mask_token() { + local token="${1}" + + if [ -n "${token}" ]; then + echo "::add-mask::$(workflow_escape "${token}")" + fi +} + +function cleanup() { + if [ -n "${BUILD_DIR}" ] && [ -d "${BUILD_DIR}" ]; then + rm -rf "${BUILD_DIR}" + fi +} + +trap cleanup EXIT + +function setup_git() { + git config --global --add safe.directory "${GITHUB_WORKSPACE:-$(pwd)}" +} + +function trim_trailing_slash() { + local value="${1}" + + while [[ "${value}" == */ ]]; do + value="${value%/}" + done + + echo "${value}" +} + +function package_owner() { + local registry_owner="${1}" + + if [ -n "${registry_owner}" ]; then + echo "${registry_owner}" + elif [ -n "${OWNER}" ]; then + echo "${OWNER}" + else + echo "${GITHUB_REPOSITORY_OWNER:-}" + fi +} + +function validate_inputs() { + mask_token "${GITHUB_TOKEN}" + mask_token "${RUBYGEMS_TOKEN}" + mask_token "${GEMCOOP_TOKEN}" + mask_token "${FORGEJO_TOKEN}" + mask_token "${GITEA_TOKEN}" + + if [ -z "${GITHUB_TOKEN}" ] && [ -z "${RUBYGEMS_TOKEN}" ] && [ -z "${GEMCOOP_TOKEN}" ] && [ -z "${FORGEJO_TOKEN}" ] && [ -z "${GITEA_TOKEN}" ]; then + fail 2 "No API keys found. Please provide at least one of: token, github_token, rubygems_token, gemcoop_token, forgejo_token, or gitea_token." + fi + + if [ -z "${OWNER}" ]; then + OWNER="${GITHUB_REPOSITORY_OWNER:-}" + fi + + if [ -n "${GITHUB_TOKEN}" ] && [ -z "${OWNER}" ]; then + fail 2 "GitHub Packages publishing requires owner or GITHUB_REPOSITORY_OWNER." + fi + + if [ -n "${FORGEJO_TOKEN}" ]; then + if [ -z "${FORGEJO_URL}" ]; then + fail 2 "Forgejo publishing requires forgejo_url." + fi + + FORGEJO_OWNER="$(package_owner "${FORGEJO_OWNER}")" + if [ -z "${FORGEJO_OWNER}" ]; then + fail 2 "Forgejo publishing requires forgejo_owner, owner, or GITHUB_REPOSITORY_OWNER." + fi + + FORGEJO_HOST="$(trim_trailing_slash "${FORGEJO_URL}")/api/packages/${FORGEJO_OWNER}/rubygems" + fi + + if [ -n "${GITEA_TOKEN}" ]; then + if [ -z "${GITEA_URL}" ]; then + fail 2 "Gitea publishing requires gitea_url." + fi + + GITEA_OWNER="$(package_owner "${GITEA_OWNER}")" + if [ -z "${GITEA_OWNER}" ]; then + fail 2 "Gitea publishing requires gitea_owner, owner, or GITHUB_REPOSITORY_OWNER." + fi + + GITEA_HOST="$(trim_trailing_slash "${GITEA_URL}")/api/packages/${GITEA_OWNER}/rubygems" + fi +} + +function setup_credentials_file() { + echo "Setting up access to RubyGems" + mkdir -p ~/.gem + touch ~/.gem/credentials + chmod 600 ~/.gem/credentials + : > ~/.gem/credentials + + if [ -n "${RUBYGEMS_TOKEN}" ]; then + echo "Configuring RubyGems.org" + echo ":rubygems_api_key: ${RUBYGEMS_TOKEN}" >> ~/.gem/credentials + REGISTRIES+=("RubyGems.org") + fi + + if [ -n "${GITHUB_TOKEN}" ]; then + echo "Configuring GitHub Packages" + echo ":github: Bearer ${GITHUB_TOKEN}" >> ~/.gem/credentials + REGISTRIES+=("GitHub Packages") + fi + + if [ -n "${GEMCOOP_TOKEN}" ]; then + echo "Configuring Gem.coop" + echo ":gemcoop: ${GEMCOOP_TOKEN}" >> ~/.gem/credentials + REGISTRIES+=("Gem.coop") + fi + + if [ -n "${FORGEJO_TOKEN}" ]; then + echo "Configuring Forgejo" + echo "${FORGEJO_HOST}: Bearer ${FORGEJO_TOKEN}" >> ~/.gem/credentials + REGISTRIES+=("Forgejo") + fi + + if [ -n "${GITEA_TOKEN}" ]; then + echo "Configuring Gitea" + echo "${GITEA_HOST}: Bearer ${GITEA_TOKEN}" >> ~/.gem/credentials + REGISTRIES+=("Gitea") + fi +} + +function resolve_gem_full_name() { + local gemspec="${1}" + local ruby_output + local exit_code + + set +e + ruby_output="$(ruby -e 'require "rubygems"; spec = Gem::Specification.load(ARGV.fetch(0)); abort "Could not load gemspec #{ARGV.fetch(0)}" unless spec; print spec.full_name' "${gemspec}" 2>&1)" + exit_code=$? + set -e + + if [ "${exit_code}" -ne 0 ]; then + echo "${ruby_output}" >&2 + fail "${exit_code}" "Failed to load ${gemspec}." + fi + + GEM_FULL_NAME="${ruby_output}" +} + +function join_by() { + local delimiter="${1}" + shift + + local joined="" + local value + for value in "$@"; do + if [ -z "${joined}" ]; then + joined="${value}" + else + joined="${joined}${delimiter}${value}" + fi + done + + echo "${joined}" +} + +function build_gems() { + local gemspecs=(*.gemspec) + + if [ "${#gemspecs[@]}" -eq 0 ]; then + fail 1 "No gemspec files found in $(pwd)." + fi + + BUILD_DIR="$(mktemp -d)" + + echo "Building gem(s)" + local gemspec + for gemspec in "${gemspecs[@]}"; do + local output + resolve_gem_full_name "${gemspec}" + output="${BUILD_DIR}/${GEM_FULL_NAME}.gem" + + set +e + gem build "${gemspec}" --output "${output}" + local exit_code=$? + set -e + + if [ "${exit_code}" -ne 0 ]; then + fail "${exit_code}" "Failed to build ${gemspec}." + fi + + if [ ! -f "${output}" ]; then + fail 1 "Gem build did not create ${output}." + fi + + BUILT_GEMS+=("${output}") + done +} + +function push_rubygems() { + local gem_file="${1}" + local gem_name + gem_name="$(basename "${gem_file}")" + + echo "Pushing ${gem_name} to RubyGems.org" + set +e + gem push "${gem_file}" + local exit_code=$? + set -e + + if [ "${exit_code}" -ne 0 ]; then + fail "${exit_code}" "Failed to push ${gem_name} to RubyGems.org." + fi +} + +function push_github_packages() { + local gem_file="${1}" + local gem_name + local host + gem_name="$(basename "${gem_file}")" + host="https://rubygems.pkg.github.com/${OWNER}" + + echo "Pushing ${gem_name} to GitHub Packages at ${host}" + set +e + gem push "${gem_file}" --key "github" --host "${host}" + local exit_code=$? + set -e + + if [ "${exit_code}" -ne 0 ]; then + fail "${exit_code}" "Failed to push ${gem_name} to GitHub Packages at ${host}." + fi +} + +function push_gemcoop() { + local gem_file="${1}" + local gem_name + gem_name="$(basename "${gem_file}")" + + echo "Pushing ${gem_name} to Gem.coop" + set +e + gem push "${gem_file}" --key "gemcoop" --host "https://gem.coop" + local exit_code=$? + set -e + + if [ "${exit_code}" -ne 0 ]; then + fail "${exit_code}" "Failed to push ${gem_name} to Gem.coop." + fi +} + +function push_forgejo() { + local gem_file="${1}" + local gem_name + gem_name="$(basename "${gem_file}")" + + echo "Pushing ${gem_name} to Forgejo at ${FORGEJO_HOST}" + set +e + gem push --host "${FORGEJO_HOST}" "${gem_file}" + local exit_code=$? + set -e + + if [ "${exit_code}" -ne 0 ]; then + fail "${exit_code}" "Failed to push ${gem_name} to Forgejo at ${FORGEJO_HOST}." + fi +} + +function push_gitea() { + local gem_file="${1}" + local gem_name + gem_name="$(basename "${gem_file}")" + + echo "Pushing ${gem_name} to Gitea at ${GITEA_HOST}" + set +e + gem push --host "${GITEA_HOST}" "${gem_file}" + local exit_code=$? + set -e + + if [ "${exit_code}" -ne 0 ]; then + fail "${exit_code}" "Failed to push ${gem_name} to Gitea at ${GITEA_HOST}." + fi +} + +function push_gems() { + local gem_file + + for gem_file in "${BUILT_GEMS[@]}"; do + if [ -n "${RUBYGEMS_TOKEN}" ]; then + push_rubygems "${gem_file}" + fi + + if [ -n "${GITHUB_TOKEN}" ]; then + push_github_packages "${gem_file}" + fi + + if [ -n "${GEMCOOP_TOKEN}" ]; then + push_gemcoop "${gem_file}" + fi + + if [ -n "${FORGEJO_TOKEN}" ]; then + push_forgejo "${gem_file}" + fi + + if [ -n "${GITEA_TOKEN}" ]; then + push_gitea "${gem_file}" + fi + done +} + +function write_summary() { + if [ -z "${GITHUB_STEP_SUMMARY:-}" ]; then + return + fi + + { + echo "## Published Ruby Gem(s)" + echo + echo "| Gem | Registries |" + echo "| --- | --- |" + + local gem_file + for gem_file in "${BUILT_GEMS[@]}"; do + echo "| $(basename "${gem_file}") | $(join_by ", " "${REGISTRIES[@]}") |" + done + } >> "${GITHUB_STEP_SUMMARY}" +} + +setup_git +validate_inputs +setup_credentials_file +build_gems +push_gems +write_summary diff --git a/test/publish_test.sh b/test/publish_test.sh new file mode 100755 index 0000000..4fb6b2f --- /dev/null +++ b/test/publish_test.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash + +set -u + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_DIRS=() +FAILURES=0 + +RUN_DIR="" +BIN_DIR="" +HOME_DIR="" +WORKSPACE_DIR="" +SUMMARY_FILE="" +STDOUT_FILE="" +STDERR_FILE="" +GEM_LOG="" +EXIT_CODE=0 + +function cleanup() { + if [ "${#TEST_DIRS[@]}" -gt 0 ]; then + rm -rf "${TEST_DIRS[@]}" + fi +} + +trap cleanup EXIT + +function prepare_run() { + local test_name="${1}" + + RUN_DIR="$(mktemp -d)" + TEST_DIRS+=("${RUN_DIR}") + BIN_DIR="${RUN_DIR}/bin" + HOME_DIR="${RUN_DIR}/home" + WORKSPACE_DIR="${RUN_DIR}/workspace" + SUMMARY_FILE="${RUN_DIR}/summary.md" + STDOUT_FILE="${RUN_DIR}/stdout" + STDERR_FILE="${RUN_DIR}/stderr" + GEM_LOG="${RUN_DIR}/gem.log" + + mkdir -p "${BIN_DIR}" "${HOME_DIR}" "${WORKSPACE_DIR}" + + cat > "${BIN_DIR}/git" <<'STUB' +#!/usr/bin/env bash +exit 0 +STUB + + cat > "${BIN_DIR}/gem" <<'STUB' +#!/usr/bin/env bash + +echo "$*" >> "${GEM_LOG}" + +case "${GEM_STUB_MODE}:${1}" in + build_failure:build) + echo "build failed" >&2 + exit 42 + ;; + no_artifact:build) + exit 0 + ;; + push_failure_rubygems:push) + if [[ "$*" != *"--host"* ]]; then + echo "Access Denied" >&2 + exit 43 + fi + exit 0 + ;; + push_failure_github:push) + if [[ "$*" == *"rubygems.pkg.github.com"* ]]; then + echo "GitHub package denied" >&2 + exit 44 + fi + exit 0 + ;; + push_failure_gemcoop:push) + if [[ "$*" == *"gem.coop"* ]]; then + echo "Gem.coop denied" >&2 + exit 45 + fi + exit 0 + ;; + push_failure_forgejo:push) + if [[ "$*" == *"forgejo.example.com"* ]]; then + echo "Forgejo denied" >&2 + exit 46 + fi + exit 0 + ;; + push_failure_gitea:push) + if [[ "$*" == *"gitea.example.com"* ]]; then + echo "Gitea denied" >&2 + exit 47 + fi + exit 0 + ;; + *:build) + output="" + while [[ "$#" -gt 0 ]]; do + case "$1" in + --output) + output="$2" + shift 2 + ;; + --output=*) + output="${1#--output=}" + shift + ;; + *) + shift + ;; + esac + done + if [[ -z "${output}" ]]; then + echo "missing --output" >&2 + exit 99 + fi + touch "${output}" + exit 0 + ;; + *:push) + exit 0 + ;; + *) + echo "unexpected gem command: $*" >&2 + exit 99 + ;; +esac +STUB + + chmod +x "${BIN_DIR}/git" "${BIN_DIR}/gem" + echo "Running ${test_name}" +} + +function add_gemspec() { + local name="${1}" + + cat > "${WORKSPACE_DIR}/${name}.gemspec" < "${WORKSPACE_DIR}/${gemspec#INVALID:}.gemspec" + ;; + *) + add_gemspec "${gemspec}" + ;; + esac + done + touch "${WORKSPACE_DIR}/stale-9.9.9.gem" + + ( + cd "${WORKSPACE_DIR}" || exit 1 + HOME="${HOME_DIR}" \ + GITHUB_WORKSPACE="${WORKSPACE_DIR}" \ + GITHUB_REPOSITORY_OWNER="${repository_owner}" \ + GITHUB_STEP_SUMMARY="${SUMMARY_FILE}" \ + PUBLISH_GEM_GITHUB_TOKEN="${github_token}" \ + PUBLISH_GEM_RUBYGEMS_TOKEN="${rubygems_token}" \ + PUBLISH_GEM_GEMCOOP_TOKEN="${gemcoop_token}" \ + PUBLISH_GEM_OWNER="${owner}" \ + PUBLISH_GEM_FORGEJO_TOKEN="${forgejo_token}" \ + PUBLISH_GEM_FORGEJO_URL="${forgejo_url}" \ + PUBLISH_GEM_FORGEJO_OWNER="${forgejo_owner}" \ + PUBLISH_GEM_GITEA_TOKEN="${gitea_token}" \ + PUBLISH_GEM_GITEA_URL="${gitea_url}" \ + PUBLISH_GEM_GITEA_OWNER="${gitea_owner}" \ + GEM_LOG="${GEM_LOG}" \ + GEM_STUB_MODE="${stub_mode}" \ + PATH="${BIN_DIR}:${PATH}" \ + "${ROOT_DIR}/script/publish" > "${STDOUT_FILE}" 2> "${STDERR_FILE}" + ) + + EXIT_CODE=$? +} + +function fail() { + local message="${1}" + + echo "FAIL: ${message}" + echo "--- stdout ---" + cat "${STDOUT_FILE}" + echo "--- stderr ---" + cat "${STDERR_FILE}" + echo "--- gem log ---" + if [ -f "${GEM_LOG}" ]; then + cat "${GEM_LOG}" + fi + echo "--- summary ---" + if [ -f "${SUMMARY_FILE}" ]; then + cat "${SUMMARY_FILE}" + fi + FAILURES=$((FAILURES + 1)) +} + +function assert_exit_code() { + local expected="${1}" + local message="${2}" + + if [ "${EXIT_CODE}" -ne "${expected}" ]; then + fail "${message}: expected exit ${expected}, got ${EXIT_CODE}" + fi +} + +function assert_file_contains() { + local file="${1}" + local expected="${2}" + local message="${3}" + + if ! grep -Fq -- "${expected}" "${file}"; then + fail "${message}: expected ${file} to contain '${expected}'" + fi +} + +function assert_file_not_contains() { + local file="${1}" + local unexpected="${2}" + local message="${3}" + + if [ -f "${file}" ] && grep -Fq -- "${unexpected}" "${file}"; then + fail "${message}: expected ${file} not to contain '${unexpected}'" + fi +} + +function assert_gem_log_count() { + local expected="${1}" + local pattern="${2}" + local message="${3}" + local actual + + actual="$(grep -Fc -- "${pattern}" "${GEM_LOG}" 2> /dev/null || true)" + if [ "${actual}" -ne "${expected}" ]; then + fail "${message}: expected ${expected} matches for '${pattern}', got ${actual}" + fi +} + +run_publish "no tokens exits 2" success "" "" "" "" "sous-chefs" "" "" "" "" "" "" alpha +assert_exit_code 2 "no tokens" +assert_file_contains "${STDOUT_FILE}" "::error::No API keys found." "no-token error command" + +run_publish "no gemspec fails clearly" success "" "ruby-token" "" "" "sous-chefs" "" "" "" "" "" "" +assert_exit_code 1 "no gemspec" +assert_file_contains "${STDOUT_FILE}" "::error::No gemspec files found" "no-gemspec error command" + +run_publish "gem build failure propagates" build_failure "" "ruby-token" "" "" "sous-chefs" "" "" "" "" "" "" alpha +assert_exit_code 42 "build failure" +assert_file_contains "${STDOUT_FILE}" "::error::Failed to build alpha.gemspec" "build failure error command" +assert_file_contains "${STDERR_FILE}" "build failed" "build stderr" + +run_publish "invalid gemspec fails with workflow error" success "" "ruby-token" "" "" "sous-chefs" "" "" "" "" "" "" INVALID:bad +assert_exit_code 1 "invalid gemspec" +assert_file_contains "${STDOUT_FILE}" "::error::Failed to load bad.gemspec" "invalid gemspec error command" + +run_publish "missing built artifact fails" no_artifact "" "ruby-token" "" "" "sous-chefs" "" "" "" "" "" "" alpha +assert_exit_code 1 "missing built artifact" +assert_file_contains "${STDOUT_FILE}" "::error::Gem build did not create" "missing artifact error command" + +run_publish "RubyGems push failure names registry" push_failure_rubygems "" "ruby-token" "" "" "sous-chefs" "" "" "" "" "" "" alpha +assert_exit_code 43 "rubygems push failure" +assert_file_contains "${STDOUT_FILE}" "::error::Failed to push alpha-0.1.0.gem to RubyGems.org" "rubygems failure error command" + +run_publish "GitHub Packages push failure names owner host" push_failure_github "github-token" "" "" "custom-owner" "sous-chefs" "" "" "" "" "" "" alpha +assert_exit_code 44 "github push failure" +assert_file_contains "${STDOUT_FILE}" "::error::Failed to push alpha-0.1.0.gem to GitHub Packages at https://rubygems.pkg.github.com/custom-owner" "github failure error command" + +run_publish "Gem.coop push failure names registry" push_failure_gemcoop "" "" "gemcoop-token" "" "sous-chefs" "" "" "" "" "" "" alpha +assert_exit_code 45 "gemcoop push failure" +assert_file_contains "${STDOUT_FILE}" "::error::Failed to push alpha-0.1.0.gem to Gem.coop" "gemcoop failure error command" + +run_publish "Forgejo push failure names registry and host" push_failure_forgejo "" "" "" "" "sous-chefs" "forgejo-token" "https://forgejo.example.com" "forgejo-owner" "" "" "" alpha +assert_exit_code 46 "forgejo push failure" +assert_file_contains "${STDOUT_FILE}" "::error::Failed to push alpha-0.1.0.gem to Forgejo at https://forgejo.example.com/api/packages/forgejo-owner/rubygems" "forgejo failure error command" + +run_publish "Gitea push failure names registry and host" push_failure_gitea "" "" "" "" "sous-chefs" "" "" "" "gitea-token" "https://gitea.example.com" "gitea-owner" alpha +assert_exit_code 47 "gitea push failure" +assert_file_contains "${STDOUT_FILE}" "::error::Failed to push alpha-0.1.0.gem to Gitea at https://gitea.example.com/api/packages/gitea-owner/rubygems" "gitea failure error command" + +run_publish "Forgejo token requires URL" success "" "" "" "" "sous-chefs" "forgejo-token" "" "" "" "" "" alpha +assert_exit_code 2 "forgejo missing url" +assert_file_contains "${STDOUT_FILE}" "::error::Forgejo publishing requires forgejo_url." "forgejo missing url error command" + +run_publish "Gitea token requires URL" success "" "" "" "" "sous-chefs" "" "" "" "gitea-token" "" "" alpha +assert_exit_code 2 "gitea missing url" +assert_file_contains "${STDOUT_FILE}" "::error::Gitea publishing requires gitea_url." "gitea missing url error command" + +run_publish "successful multi-gemspec publish pushes current artifacts" success "github-token" "ruby-token" "gemcoop-token" "" "sous-chefs" "" "" "" "" "" "" alpha beta +assert_exit_code 0 "successful publish" +assert_gem_log_count 1 "build alpha.gemspec --output" "alpha built once" +assert_gem_log_count 1 "build beta.gemspec --output" "beta built once" +assert_gem_log_count 6 "push " "only current built gems pushed to configured registries" +assert_file_contains "${GEM_LOG}" "push " "push commands logged" +assert_file_contains "${GEM_LOG}" "alpha-0.1.0.gem" "alpha pushed" +assert_file_contains "${GEM_LOG}" "beta-0.1.0.gem" "beta pushed" +assert_file_not_contains "${GEM_LOG}" "stale-9.9.9.gem" "stale workspace gem not pushed" +assert_file_contains "${GEM_LOG}" "https://rubygems.pkg.github.com/sous-chefs" "default owner used" +assert_file_contains "${SUMMARY_FILE}" "RubyGems.org" "summary includes rubygems" +assert_file_not_contains "${SUMMARY_FILE}" "github-token" "summary excludes github token" +assert_file_not_contains "${SUMMARY_FILE}" "ruby-token" "summary excludes rubygems token" +assert_file_not_contains "${SUMMARY_FILE}" "gemcoop-token" "summary excludes gemcoop token" + +run_publish "Forgejo and Gitea publish use documented package hosts" success "" "" "" "fallback-owner" "sous-chefs" "forgejo-token" "https://forgejo.example.com/" "" "gitea-token" "https://gitea.example.com" "gitea-owner" alpha +assert_exit_code 0 "forgejo and gitea publish" +assert_file_contains "${HOME_DIR}/.gem/credentials" "https://forgejo.example.com/api/packages/fallback-owner/rubygems: Bearer forgejo-token" "forgejo credentials" +assert_file_contains "${HOME_DIR}/.gem/credentials" "https://gitea.example.com/api/packages/gitea-owner/rubygems: Bearer gitea-token" "gitea credentials" +assert_file_contains "${GEM_LOG}" "push --host https://forgejo.example.com/api/packages/fallback-owner/rubygems" "forgejo push host" +assert_file_contains "${GEM_LOG}" "push --host https://gitea.example.com/api/packages/gitea-owner/rubygems" "gitea push host" +assert_file_contains "${STDOUT_FILE}" "::add-mask::forgejo-token" "forgejo token masked" +assert_file_contains "${STDOUT_FILE}" "::add-mask::gitea-token" "gitea token masked" +assert_file_contains "${SUMMARY_FILE}" "Forgejo" "summary includes forgejo" +assert_file_contains "${SUMMARY_FILE}" "Gitea" "summary includes gitea" +assert_file_not_contains "${SUMMARY_FILE}" "forgejo-token" "summary excludes forgejo token" +assert_file_not_contains "${SUMMARY_FILE}" "gitea-token" "summary excludes gitea token" + +run_publish "token aliases github_token" success "alias-token" "" "" "" "sous-chefs" "" "" "" "" "" "" alpha +assert_exit_code 0 "token alias publish" +assert_file_contains "${HOME_DIR}/.gem/credentials" ":github: Bearer alias-token" "alias token credentials" +assert_file_contains "${STDOUT_FILE}" "::add-mask::alias-token" "alias token masked" + +run_publish "explicit owner overrides repository owner" success "github-token" "" "" "explicit-owner" "sous-chefs" "" "" "" "" "" "" alpha +assert_exit_code 0 "explicit owner publish" +assert_file_contains "${GEM_LOG}" "https://rubygems.pkg.github.com/explicit-owner" "explicit owner used" + +run_publish "tokens are masked before publishing" success "github-token" "ruby-token" "gemcoop-token" "" "sous-chefs" "forgejo-token" "https://forgejo.example.com" "" "gitea-token" "https://gitea.example.com" "" alpha +assert_exit_code 0 "mask publish" +assert_file_contains "${STDOUT_FILE}" "::add-mask::github-token" "github token masked" +assert_file_contains "${STDOUT_FILE}" "::add-mask::ruby-token" "rubygems token masked" +assert_file_contains "${STDOUT_FILE}" "::add-mask::gemcoop-token" "gemcoop token masked" +assert_file_contains "${STDOUT_FILE}" "::add-mask::forgejo-token" "forgejo token masked" +assert_file_contains "${STDOUT_FILE}" "::add-mask::gitea-token" "gitea token masked" + +if [ "${FAILURES}" -gt 0 ]; then + echo "${FAILURES} test failure(s)" + exit 1 +fi + +echo "All publish tests passed"