From b2362275ef8586ca0f94cc8f7114a19ecb600342 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 24 Aug 2026 14:51:02 -0600 Subject: [PATCH] Port release workflows and scripts from main to 3.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring 3.x in line with main's automated release pipeline so 3.x maintenance releases can be cut via workflow_dispatch instead of a maintainer running scripts locally. Ported from main: - .github/workflows/release.yml — workflow_dispatch that runs script/release with major/minor/patch inputs and opens a release PR. - .github/workflows/publish-release.yml — triggers on push to 3.x (or manual dispatch) and runs script/publish. - .github/workflows/push_gem.yml — pushes the tagged gem to RubyGems via Trusted Publishing. - script/release — includes every fix that landed on main (DEPRECATION_HORIZON sed on the quoted string, main "$@", bundle frozen-mode fixes, lockfile sed instead of bundle lock, idempotent push/PR creation, hard-failing docs build). - script/publish — includes all main fixes (GITHUB_TOKEN-compatible permission probe, explicit push_gem dispatch against tag ref). - script/sync_contributors.rb — needed by build_docs. 3.x-specific adjustments: - publish-release.yml triggers on push to 3.x, not main. - script/release accepts both main and 3.x as valid release branches. - The release PR base is set from branch_name() so the same script cuts main-targeted PRs on main and 3.x-targeted PRs on 3.x. - script/publish only publishes gh-pages when run from main. gh-pages hosts a single docs site, so publishing from 3.x would clobber the current (4.x) docs. 3.x still gets tagged and released — just no gh-pages update. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 217f0a0d-a9f2-4607-9643-5fcb3e34f20f --- .github/workflows/publish-release.yml | 56 +++++++ .github/workflows/push_gem.yml | 41 +++++ .github/workflows/release.yml | 133 +++++++++++++++ script/publish | 94 ++++++++++- script/release | 223 ++++++++++++++++++-------- script/sync_contributors.rb | 83 ++++++++++ 6 files changed, 559 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/publish-release.yml create mode 100644 .github/workflows/push_gem.yml create mode 100644 .github/workflows/release.yml create mode 100755 script/sync_contributors.rb diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000..4bc605191 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,56 @@ +name: Publish Release + +on: + push: + branches: + - 3.x + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +jobs: + publish: + name: Publish release + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + # Only run if this is a release commit created by script/release. + # Match either the direct commit message from script/release + # ("release X.Y.Z") or the merge-commit body containing the release + # PR title ("Release X.Y.Z"), so a normal PR merge still triggers. + # Also always run on manual workflow_dispatch. + if: | + github.event_name == 'workflow_dispatch' || + startsWith(github.event.head_commit.message, 'release ') || + contains(github.event.head_commit.message, 'Release ') + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: true + + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b + with: + ruby-version: 4.0 + bundler-cache: true + + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c + with: + egress-policy: audit + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Publish release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + script/publish diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml new file mode 100644 index 000000000..8d5278535 --- /dev/null +++ b/.github/workflows/push_gem.yml @@ -0,0 +1,41 @@ +name: Push Gem + +on: + push: + tags: + - v* + workflow_dispatch: + +permissions: + contents: read + +jobs: + push: + if: github.repository == 'ViewComponent/view_component' + runs-on: ubuntu-latest + + permissions: + contents: write + id-token: write + + # GitHub environment configured on RubyGems + environment: release + + steps: + # Set up + - name: Harden Runner + uses: step-security/harden-runner@v2 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: 4.0 + + # Release + - uses: rubygems/release-gem@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..5958ea2b0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Release + +on: + workflow_dispatch: + inputs: + major: + description: 'Major version' + required: true + type: string + minor: + description: 'Minor version' + required: true + type: string + patch: + description: 'Patch version' + required: true + type: string + pre: + description: 'Pre-release version (leave empty for release)' + required: false + type: string + +permissions: + contents: read + pull-requests: read + +jobs: + validate-inputs: + name: Validate version inputs + runs-on: ubuntu-latest + steps: + - name: Validate inputs are numeric + env: + MAJOR: ${{ inputs.major }} + MINOR: ${{ inputs.minor }} + PATCH: ${{ inputs.patch }} + PRE: ${{ inputs.pre }} + run: | + if ! [[ "$MAJOR" =~ ^[0-9]+$ ]]; then + echo "Error: major version must be numeric" + exit 1 + fi + if ! [[ "$MINOR" =~ ^[0-9]+$ ]]; then + echo "Error: minor version must be numeric" + exit 1 + fi + if ! [[ "$PATCH" =~ ^[0-9]+$ ]]; then + echo "Error: patch version must be numeric" + exit 1 + fi + if [ -n "$PRE" ] && ! [[ "$PRE" =~ ^[a-zA-Z0-9.-]+$ ]]; then + echo "Error: pre-release version contains invalid characters" + exit 1 + fi + + prepare-release: + name: Get current version + needs: validate-inputs + runs-on: ubuntu-latest + outputs: + current-major: ${{ steps.current-version.outputs.major }} + current-minor: ${{ steps.current-version.outputs.minor }} + current-patch: ${{ steps.current-version.outputs.patch }} + current-pre: ${{ steps.current-version.outputs.pre }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Read current version + id: current-version + run: | + major=$(grep "MAJOR = " lib/view_component/version.rb | grep -o "[0-9]\+") + minor=$(grep "MINOR = " lib/view_component/version.rb | grep -o "[0-9]\+") + patch=$(grep "PATCH = " lib/view_component/version.rb | grep -o "[0-9]\+") + pre=$(grep "PRE = " lib/view_component/version.rb | grep -o "\"[^\"]*\"" | tr -d '"') + echo "major=$major" >> $GITHUB_OUTPUT + echo "minor=$minor" >> $GITHUB_OUTPUT + echo "patch=$patch" >> $GITHUB_OUTPUT + echo "pre=$pre" >> $GITHUB_OUTPUT + + - name: Show current version + env: + CURRENT_MAJOR: ${{ steps.current-version.outputs.major }} + CURRENT_MINOR: ${{ steps.current-version.outputs.minor }} + CURRENT_PATCH: ${{ steps.current-version.outputs.patch }} + CURRENT_PRE: ${{ steps.current-version.outputs.pre }} + INPUT_MAJOR: ${{ inputs.major }} + INPUT_MINOR: ${{ inputs.minor }} + INPUT_PATCH: ${{ inputs.patch }} + INPUT_PRE: ${{ inputs.pre }} + run: | + current="$CURRENT_MAJOR.$CURRENT_MINOR.$CURRENT_PATCH" + [ -n "$CURRENT_PRE" ] && current="$current-$CURRENT_PRE" + echo "Current version: $current" + release="$INPUT_MAJOR.$INPUT_MINOR.$INPUT_PATCH" + [ -n "$INPUT_PRE" ] && release="$release-$INPUT_PRE" + echo "Release version: $release" + + release: + name: Create release + needs: [prepare-release, validate-inputs] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: true + + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b + with: + ruby-version: 4.0 + bundler-cache: true + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MAJOR: ${{ inputs.major }} + MINOR: ${{ inputs.minor }} + PATCH: ${{ inputs.patch }} + PRE: ${{ inputs.pre }} + run: | + script/release "$MAJOR" "$MINOR" "$PATCH" "$PRE" diff --git a/script/publish b/script/publish index e5f5680e8..d48a464fe 100755 --- a/script/publish +++ b/script/publish @@ -1,10 +1,92 @@ #!/usr/bin/env bash +set -e + +# Check GitHub permissions +check_github_permissions() { + if ! gh auth status > /dev/null 2>&1; then + echo "Error: not authenticated with GitHub. Run 'gh auth login'" + exit 1 + fi + + # Check if user has push access to the repo + if ! gh repo view ViewComponent/view_component > /dev/null 2>&1; then + echo "Error: cannot access ViewComponent/view_component repository" + exit 1 + fi + + # Skip the push-permission probe when running in GitHub Actions. + # GET /repos/:owner/:repo only returns `.permissions` for user/OAuth + # tokens — for the workflow's GITHUB_TOKEN the field is absent, so + # the probe would incorrectly report no push access even when the + # workflow has `contents: write`. In CI we rely on the job's declared + # permissions; failures will surface from the actual git push / gh + # release create calls below with clear messages. + if [ -n "${GITHUB_ACTIONS:-}" ]; then + return 0 + fi + + # Check if user can create releases (requires write access) + # Attempt a dry-run by checking repo permissions + local perms=$(gh api repos/ViewComponent/view_component -q '.permissions.push // false') + if [ "$perms" != "true" ]; then + echo "Error: insufficient permissions to create releases in ViewComponent/view_component" + echo "You need at least 'push' (write) access to the repository" + exit 1 + fi +} + +# Get version from version.rb +major=$(grep "MAJOR = " lib/view_component/version.rb | grep -o "[0-9]\+") +minor=$(grep "MINOR = " lib/view_component/version.rb | grep -o "[0-9]\+") +patch=$(grep "PATCH = " lib/view_component/version.rb | grep -o "[0-9]\+") +version="$major.$minor.$patch" +tag="v$version" + +# Check permissions before proceeding +check_github_permissions + +# Create and push git tag (idempotent so re-running after a partial +# release doesn't abort on an already-existing tag) +if ! git rev-parse --quiet --verify "refs/tags/$tag" > /dev/null; then + git tag "$tag" +fi +git push origin "$tag" || true + +# Extract changelog for this version +# Find the section for this version and capture until the next ## heading +changelog=$(sed -n "/^## $version$/,/^## /p" docs/CHANGELOG.md | sed '$ d') + # Publish gem -bundle exec rake release +# this step has been replaced by .github/workflows/push_gem.yml + +# Publish updated docs — but only from main. gh-pages hosts a single +# docs site, so publishing from 3.x would clobber the current (4.x) +# docs. 3.x is a maintenance branch: skip gh-pages here. +current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") +if [ "$current_branch" = "main" ]; then + git branch -D gh-pages 2>/dev/null || true + git checkout -b gh-pages main + git push origin gh-pages --force + git checkout main +else + echo "Skipping gh-pages publish (current branch is '$current_branch', not 'main')" +fi + +# Create GitHub release +gh release create "$tag" \ + --title "$version" \ + --notes "$changelog" \ + --repo ViewComponent/view_component -# Publish updated docs -git branch -D gh-pages -git checkout -b gh-pages main -git push origin gh-pages --force -git checkout main +# Kick off the Push Gem workflow explicitly. GitHub Actions does not +# trigger downstream workflows for events driven by GITHUB_TOKEN — so +# the tag push above will not fire push_gem.yml on its own when this +# script runs inside the publish-release workflow. Dispatch it here so +# 'script/publish' always results in a gem being pushed to RubyGems. +# +# Dispatch against the tag ref (not main) because the 'release' +# environment restricts deployments to v* tags. +gh workflow run push_gem.yml \ + --repo ViewComponent/view_component \ + --ref "$tag" || true diff --git a/script/release b/script/release index cbbf7d7aa..4934199ad 100755 --- a/script/release +++ b/script/release @@ -1,5 +1,12 @@ #!/usr/bin/env bash +# bundler-cache: true in setup-ruby enables frozen mode, which blocks +# updating Gemfile.lock after the gemspec version bump. Disable it for +# every bundler invocation in this script so the lockfile can be +# regenerated to match the new version. +export BUNDLE_FROZEN=false +export BUNDLE_DEPLOYMENT=false + fetch() { git fetch --all } @@ -32,8 +39,7 @@ create_release_branch() { } update_readme() { - sed -i '' "/## main/ {a\ - \\ + sed -i "/## main/ {a\\ \\ ## $1.$2.$3 }" docs/CHANGELOG.md @@ -41,34 +47,52 @@ update_readme() { update_ruby_version() { # Update version file - sed -E -i '' \ - -e "s/MAJOR = [0-9]+/MAJOR = $1/g" \ - -e "s/MINOR = [0-9]+/MINOR = $2/g" \ - -e "s/PATCH = [0-9]+/PATCH = $3/g" \ + sed -i \ + -e "s/MAJOR = [0-9]*/MAJOR = $1/g" \ + -e "s/MINOR = [0-9]*/MINOR = $2/g" \ + -e "s/PATCH = [0-9]*/PATCH = $3/g" \ lib/view_component/version.rb # Update deprecation horizon version major=$1 - sed -E -i '' \ - -e "s/DEPRECATION_HORIZON = [0-9]+/DEPRECATION_HORIZON = $((major + 1))/g" \ + sed -i \ + -e "s/DEPRECATION_HORIZON = \"[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\"/DEPRECATION_HORIZON = \"$((major + 1)).0.0\"/g" \ lib/view_component/deprecation.rb } update_gemfiles() { - # Update Gemfile.lock - bundle + major=$1 + minor=$2 + patch=$3 + new_version="$major.$minor.$patch" + + # The only lockfile change during a release is the view_component + # version pinned by the path gemspec. Update it in place across every + # lockfile. Running `bundle lock` from a mismatched Ruby (e.g. ruby-head + # appraisals from Ruby 4.0) produces lockfiles CI cannot consume in + # frozen mode, so we edit them directly instead. + for lockfile in Gemfile.lock gemfiles/*.gemfile.lock; do + [ -f "$lockfile" ] || continue + sed -i \ + -e "s/^\( view_component \)([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*)/\1($new_version)/" \ + -e "s/^\( view_component \)([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*)/\1($new_version)/" \ + "$lockfile" + done } build_docs() { - bundle exec rake docs:build + ruby script/sync_contributors.rb || { echo "Error: sync_contributors.rb failed"; exit 1; } + bundle exec rake docs:build || { echo "Error: rake docs:build failed"; exit 1; } } add_changed_files() { git add \ docs/api.md \ docs/CHANGELOG.md \ + docs/_data/contributors.yml \ docs/_data/library.yml \ Gemfile.lock \ + gemfiles/*.gemfile.lock \ lib/view_component/version.rb } @@ -77,21 +101,116 @@ commit() { } push() { - git push origin release-$1-$2-$3 + # Force-push: the release-X-Y-Z branch is auto-generated and is safe + # to overwrite. A previous failed run may have left the branch on the + # remote, which would otherwise reject a non-fast-forward push. + git push --force origin release-$1-$2-$3 + + # Determine the PR base branch. On this branch the release targets + # 3.x, but detect dynamically so the same script can be used from + # either main or 3.x. + base_branch=$(branch_name) + + # Open PR (or reuse an existing one from a previous failed run). + pr_url=$(gh pr view "release-$1-$2-$3" \ + --repo ViewComponent/view_component \ + --json url --jq .url 2>/dev/null || true) + if [ -z "$pr_url" ]; then + pr_url=$(gh pr create \ + --title "Release $1.$2.$3" \ + --body "Automated release PR for version $1.$2.$3." \ + --base "$base_branch" \ + --head release-$1-$2-$3 \ + --repo ViewComponent/view_component) + else + echo "Reusing existing PR: $pr_url" + fi echo "####################################################" - echo "Now, open a PR with this branch and merge it to main" - echo "Then, run script/publish on main to release the gem" - echo "Finally, create a GitHub release https://github.com/viewcomponent/view_component/releases/new with the changes from docs/CHANGELOG" + echo "PR opened: $pr_url" + echo "" + echo "After merging to $base_branch, run:" + echo " git checkout $base_branch" + echo " git pull" + echo " script/publish" + echo "" + echo "This will:" + echo " 1. Publish updated docs to gh-pages (main branch only)" + echo " 2. Create a GitHub release with CHANGELOG entries" + echo " 3. The gem will be pushed to RubyGems automatically by" + echo " Github Actions using Trusted Publishing." echo "####################################################" } main() { - version=$(ruby ./lib/view_component/version.rb) - version=(${version//./ }) - major=${version[0]} - minor=${version[1]} - patch=${version[2]} + major=$(grep "MAJOR = " lib/view_component/version.rb | grep -o "[0-9]\+") + minor=$(grep "MINOR = " lib/view_component/version.rb | grep -o "[0-9]\+") + patch=$(grep "PATCH = " lib/view_component/version.rb | grep -o "[0-9]\+") + pre=$(grep "PRE = " lib/view_component/version.rb | grep -o "\"[^\"]*\"" | tr -d '"') + + # If arguments provided, use them (for CI/workflow usage) + if [ $# -gt 0 ]; then + major=$1 + minor=$2 + patch=$3 + [ $# -gt 3 ] && pre=$4 || pre="" + else + # Interactive mode + echo "===================" + echo "Prerequisite Checks" + echo "===================" + + if ! working_tree_is_clean; then + echo "Error: unclean working tree" + exit 1 + fi + + if [ "$(branch_name)" != "main" ] && [ "$(branch_name)" != "3.x" ]; then + echo "Error: can only make a release on the main or 3.x branch" + exit 1 + fi + + fetch + + if ! remote_history_is_clean; then + echo "Error: changes exist on origin not pulled into this branch. Please pull" + exit 1 + fi + + if ! local_history_is_clean; then + echo "Error: changes exist that haven't been pushed to origin. Please pull" + exit 1 + fi + + echo "Type the number of an option to bump, or pick Manual to enter a version number" + select bump in Major Minor Patch Manual + do + if [ "$bump" == "Major" ]; then + major=$((major + 1)) + minor=0 + patch=0 + elif [ "$bump" == "Minor" ]; then + minor=$((minor + 1)) + patch=0 + elif [ "$bump" == "Patch" ]; then + patch=$((patch + 1)) + else + read -p "What version? (Currently $major.$minor.$patch): " new_version + if [ "$new_version" == "$major.$minor.$patch" ]; then + echo "Error: Can't be the same version" + exit 1 + fi + + new_version=(${new_version//./ }) + + major=${new_version[0]} + minor=${new_version[1]} + patch=${new_version[2]} + fi + + break + done + fi echo "===================" echo "Prerequisite Checks" @@ -102,7 +221,7 @@ main() { exit 1 fi - if [[ "$(branch_name)" != "main" && "$(branch_name)" != "3.x" ]]; then + if [ "$(branch_name)" != "main" ] && [ "$(branch_name)" != "3.x" ]; then echo "Error: can only make a release on the main or 3.x branch" exit 1 fi @@ -119,51 +238,25 @@ main() { exit 1 fi - echo "Type the number of an option to bump, or pick Manual to enter a version number" - select bump in Major Minor Patch Manual - do - if [ "$bump" == "Major" ]; then - major=$((major + 1)) - minor=0 - patch=0 - elif [ "$bump" == "Minor" ]; then - minor=$((minor + 1)) - patch=0 - elif [ "$bump" == "Patch" ]; then - patch=$((patch + 1)) - else - read -p "What version? (Currently $major.$minor.$patch): " new_version - if [ "$new_version" == "$major.$minor.$patch" ]; then - echo "Error: Can't be the same version" - exit 1 - fi - - new_version=(${new_version//./ }) - - major=${new_version[0]} - minor=${new_version[1]} - patch=${new_version[2]} - fi + if tag_exists_on_remote $major $minor $patch; then + echo "Error: tag exists on remote" + exit 1 + fi - if tag_exists_on_remote $major $minor $patch; then - echo "Error: tag exists on remote" - exit 1 - fi + echo "===============================" + echo "Creating release for $major.$minor.$patch" + echo "===============================" - echo "===============================" - echo "Creating release for $major.$minor.$patch" - echo "===============================" - - create_release_branch $major $minor $patch - update_readme $major $minor $patch - update_ruby_version $major $minor $patch - update_gemfiles $major $minor $patch - echo "version: $major.$minor.$patch" > docs/_data/library.yml - add_changed_files $major $minor $patch - commit $major $minor $patch - push $major $minor $patch - exit 0 - done + create_release_branch $major $minor $patch + update_readme $major $minor $patch + update_ruby_version $major $minor $patch + update_gemfiles $major $minor $patch + echo "version: $major.$minor.$patch" > docs/_data/library.yml + build_docs + add_changed_files $major $minor $patch + commit $major $minor $patch + push $major $minor $patch + exit 0 } -main +main "$@" diff --git a/script/sync_contributors.rb b/script/sync_contributors.rb new file mode 100755 index 000000000..215593d8a --- /dev/null +++ b/script/sync_contributors.rb @@ -0,0 +1,83 @@ +require "json" +require "net/http" +require "uri" +require "yaml" +require "fileutils" + +# Fetch all contributors from GitHub API (handling pagination) +def fetch_contributors + all_contributors = [] + page = 1 + per_page = 100 # Maximum allowed by GitHub API + + loop do + uri = URI("https://api.github.com/repos/viewcomponent/view_component/contributors") + uri.query = URI.encode_www_form(page: page, per_page: per_page) + + contributors = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| + request = Net::HTTP::Get.new(uri) + # Add User-Agent header as GitHub API recommends it + request["User-Agent"] = "ViewComponent Contributors Sync Script" + + response = http.request(request) + + if response.code == "200" + JSON.parse(response.body) + else + puts "Error fetching contributors (page #{page}): #{response.code} #{response.message}" + exit 1 + end + end + + # Break if no more contributors on this page + break if contributors.empty? + + all_contributors.concat(contributors) + puts "Fetched page #{page} with #{contributors.length} contributors" + + # Break if we got less than the full page size (last page) + break if contributors.length < per_page + + page += 1 + end + + all_contributors +end + +# Transform contributors data for YAML output +def transform_contributors(contributors) + usernames = contributors.map do |contributor| + contributor["login"].downcase + end.sort + + {"usernames" => usernames} +end + +# Write contributors data to YAML file +def write_contributors_yaml(data) + output_path = File.join(__dir__, "..", "docs", "_data", "contributors.yml") + + # Ensure the directory exists + FileUtils.mkdir_p(File.dirname(output_path)) + + File.write(output_path, data.to_yaml) + puts "Contributors synced to #{output_path}" + puts "Found #{data["usernames"].length} contributors" +end + +# Main execution +begin + puts "Fetching contributors from GitHub API..." + contributors = fetch_contributors + + puts "Transforming contributor data..." + transformed_data = transform_contributors(contributors) + + puts "Writing to YAML file..." + write_contributors_yaml(transformed_data) + + puts "Successfully synced contributors!" +rescue => e + puts "Error: #{e.message}" + exit 1 +end