diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index e7f1b41..d822ad8 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -118,3 +118,136 @@ jobs: with: name: page_print-darwin-arm64-gem path: pkg/page_print-*-arm64-darwin.gem + + publish: + name: Publish + needs: [source, linux, darwin-arm64] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.7" + + - uses: actions/download-artifact@v4 + with: + path: gems + pattern: page_print-*-gem + merge-multiple: true + + - name: Expect exact gem artifacts for tag version + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + expected=( + "page_print-${version}.gem" + "page_print-${version}-x86_64-linux.gem" + "page_print-${version}-arm64-darwin.gem" + ) + + # download-artifact may nest files; flatten expected gems to gems/ + mkdir -p gems + for name in "${expected[@]}"; do + if [[ ! -f "gems/${name}" ]]; then + match="$(find gems -type f -name "${name}" | head -n 1 || true)" + [[ -n "${match}" ]] || { echo "Missing expected gem artifact: ${name}" >&2; exit 1; } + mv "${match}" "gems/${name}" + fi + done + + mapfile -t found < <(find gems -type f -name 'page_print-*.gem' -printf '%f\n' | sort) + printf 'Found gems:\n' + printf ' %s\n' "${found[@]}" + + missing=() + for name in "${expected[@]}"; do + [[ -f "gems/${name}" ]] || missing+=("${name}") + done + unexpected=() + for base in "${found[@]}"; do + keep=false + for name in "${expected[@]}"; do + [[ "${base}" == "${name}" ]] && keep=true && break + done + [[ "${keep}" == true ]] || unexpected+=("${base}") + done + + if ((${#missing[@]})); then + echo "Missing expected gem artifacts: ${missing[*]}" >&2 + exit 1 + fi + if ((${#unexpected[@]})); then + echo "Unexpected gem artifacts: ${unexpected[*]}" >&2 + exit 1 + fi + + - uses: rubygems/configure-rubygems-credentials@v2.1.0 + + - name: Push gems to RubyGems + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + already_published() { + printf '%s' "$1" | grep -Eqi 'repushing of gem versions is not allowed|already (exists|been pushed|published)' + } + + for name in \ + "page_print-${version}.gem" \ + "page_print-${version}-x86_64-linux.gem" \ + "page_print-${version}-arm64-darwin.gem" + do + echo "Pushing ${name}" + set +e + output="$(gem push "gems/${name}" 2>&1)" + status=$? + set -e + printf '%s\n' "${output}" + if [[ "${status}" -eq 0 ]]; then + continue + fi + if already_published "${output}"; then + echo "${name} is already published, skipping" + continue + fi + echo "Failed to publish ${name}" >&2 + exit 1 + done + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + tag="${GITHUB_REF_NAME}" + + prev="$(git tag -l 'v*' --sort=-v:refname | awk -v current="${tag}" '$0 != current { print; exit }')" + if [[ -n "${prev}" ]]; then + notes="$(git log "${prev}..${tag}" --oneline)" + [[ -n "${notes}" ]] || notes="No commits since ${prev}." + else + notes="Initial release." + fi + + assets=( + "gems/page_print-${version}.gem" + "gems/page_print-${version}-x86_64-linux.gem" + "gems/page_print-${version}-arm64-darwin.gem" + ) + + if gh release view "${tag}" >/dev/null 2>&1; then + echo "Release ${tag} already exists; refreshing assets" + gh release upload "${tag}" "${assets[@]}" --clobber + else + gh release create "${tag}" --title "${tag}" --notes "${notes}" "${assets[@]}" + fi diff --git a/README.md b/README.md index 6cb99ed..7edc0a9 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,18 @@ gem build page_print.gemspec gem install ./page_print-*.gem ``` +## Releasing + +Publish a new version with: + +```sh +bin/bump 0.1.6 +``` + +That updates `lib/page_print/version.rb`, commits `Release 0.1.6`, creates annotated tag `v0.1.6`, and pushes `main` plus the tag. Requires a clean worktree on `main`. + +The Package workflow then builds the source and platform gems, pushes them to RubyGems (trusted publishing / OIDC, GitHub environment `release`), and creates a GitHub Release with the gem artifacts and commit notes since the previous `v*` tag. + ## Building Native Gems Build an `x86_64-linux` platform gem with a vendored PlutoBook library: diff --git a/bin/bump b/bin/bump new file mode 100755 index 0000000..cf9e30b --- /dev/null +++ b/bin/bump @@ -0,0 +1,66 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "open3" +require "shellwords" + +ROOT = File.expand_path("..", __dir__) +VERSION_FILE = File.join(ROOT, "lib/page_print/version.rb") + +def abort_with(message) + warn message + exit 1 +end + +def run(*command, capture: false) + if capture + output, status = Open3.capture2e(*command, chdir: ROOT) + abort_with("Command failed: #{command.shelljoin}\n#{output}") unless status.success? + + output + else + system(*command, chdir: ROOT) || abort_with("Command failed: #{command.shelljoin}") + end +end + +def usage + "Usage: bin/bump X.Y.Z\nExample: bin/bump 0.1.6" +end + +def parse_version(version) + version.split(".").map(&:to_i) +end + +abort_with(usage) unless ARGV.length == 1 + +version = ARGV.fetch(0) +abort_with("Version must be X.Y.Z (digits only)") unless version.match?(/\A\d+\.\d+\.\d+\z/) + +branch = run("git", "branch", "--show-current", capture: true).strip +abort_with("Must be on main (currently #{branch.empty? ? "detached HEAD" : branch})") unless branch == "main" + +status = run("git", "status", "--porcelain", capture: true) +abort_with("Worktree is dirty:\n#{status}") unless status.empty? + +contents = File.read(VERSION_FILE) +match = contents.match(/VERSION\s*=\s*['"]([^'"]+)['"]/) +abort_with("Could not parse VERSION from lib/page_print/version.rb") unless match + +current = match[1] +unless (parse_version(version) <=> parse_version(current)) == 1 + abort_with("Version #{version} must be greater than current #{current}") +end + +tag = "v#{version}" +abort_with("Tag #{tag} already exists locally") unless run("git", "tag", "-l", tag, capture: true).strip.empty? +abort_with("Tag #{tag} already exists on origin") unless run("git", "ls-remote", "--tags", "origin", "refs/tags/#{tag}", capture: true).strip.empty? + +File.write(VERSION_FILE, contents.sub(/VERSION\s*=\s*['"][^'"]+['"]/, "VERSION = '#{version}'")) + +run("git", "add", "lib/page_print/version.rb") +run("git", "commit", "-m", "Release #{version}") +run("git", "tag", "-a", tag, "-m", "Release #{version}") +run("git", "push", "origin", "main") +run("git", "push", "origin", tag) + +puts "Pushed #{tag}. CI Package workflow will publish gems and create the GitHub Release." diff --git a/bin/release b/bin/release deleted file mode 100755 index 82bfe13..0000000 --- a/bin/release +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "fileutils" -require "io/console" -require "open3" -require "shellwords" - -ROOT = File.expand_path("..", __dir__) - -def abort_with(message) - warn message - exit 1 -end - -def run(*command, env: {}, capture: false) - if capture - output, status = Open3.capture2e(env, *command, chdir: ROOT) - abort_with("Command failed: #{command.shelljoin}\n#{output}") unless status.success? - - output - else - system(env, *command, chdir: ROOT) || abort_with("Command failed: #{command.shelljoin}") - end -end - -def require_command(name) - system("command", "-v", name, out: File::NULL) || abort_with("#{name} is required") -end - -def find_gems(directory) - Dir.glob(File.join(directory, "**", "*.gem")).sort -end - -def usage - "Usage: bin/release --run RUN_ID\nExample: bin/release --run 12345678901" -end - -def run_id_from_args(args) - abort_with(usage) unless args.length == 2 && args[0] == "--run" - abort_with("Run ID must be numeric") unless args[1].match?(/\A\d+\z/) - - args[1] -end - -def gem_version(name) - case name - when /\Apage_print-(.+)-x86_64-linux\.gem\z/ - Regexp.last_match(1) - when /\Apage_print-(.+)-arm64-darwin\.gem\z/ - Regexp.last_match(1) - when /\Apage_print-(.+)\.gem\z/ - Regexp.last_match(1) - end -end - -def expected_names(version) - [ - "page_print-#{version}.gem", - "page_print-#{version}-x86_64-linux.gem", - "page_print-#{version}-arm64-darwin.gem" - ] -end - -def prompt_otp(gem_name) - print "RubyGems OTP for #{gem_name} (blank to cancel): " - otp = begin - $stdin.noecho(&:gets) - rescue NoMethodError - $stdin.gets - end - puts - - otp&.strip -end - -def already_published?(output) - output.match?(/repushing of gem versions is not allowed/i) || - output.match?(/already (?:exists|been pushed|published)/i) -end - -def otp_rejected?(output) - output.match?(/otp code is incorrect/i) || output.match?(/otp.*(?:invalid|incorrect|expired)/i) -end - -def push_gem(path) - gem_name = File.basename(path) - - loop do - otp = prompt_otp(gem_name) - abort_with("Release cancelled") if otp.nil? || otp.empty? - - output, status = Open3.capture2e("gem", "push", path, "--otp", otp, chdir: ROOT) - - if status.success? - puts output - return - end - - if already_published?(output) - puts "#{gem_name} is already published, skipping" - return - end - - if otp_rejected?(output) - warn output - warn "OTP rejected, try a fresh code." - next - end - - abort_with("Failed to publish #{gem_name}:\n#{output}") - end -end - -run_id = run_id_from_args(ARGV) -release_dir = File.join(ROOT, "tmp", "release-run-#{run_id}") - -require_command("gh") -require_command("gem") - -gems = find_gems(release_dir) - -if gems.empty? - run("gh", "auth", "status") - - puts "Downloading artifacts from workflow run #{run_id}" - - FileUtils.mkdir_p(release_dir) - run("gh", "run", "download", run_id.to_s, "--dir", release_dir) - - gems = find_gems(release_dir) -else - puts "Using existing artifacts in #{release_dir}" -end - -abort_with("No gem artifacts found in workflow run #{run_id}") if gems.empty? - -found_names = gems.map { |path| File.basename(path) } -versions = found_names.map { |name| gem_version(name) }.compact.uniq - -abort_with("Unexpected gem artifacts found: #{found_names.join(", ")}") unless versions.length == 1 - -version = versions.fetch(0) -expected = expected_names(version) -missing = expected - found_names -unexpected = found_names - expected - -abort_with("Missing expected gem artifacts: #{missing.join(", ")}") unless missing.empty? -abort_with("Unexpected gem artifacts found: #{unexpected.join(", ")}") unless unexpected.empty? - -gems = expected.map { |name| gems.find { |path| File.basename(path) == name } } - -puts "\nGems to publish:" -gems.each { |path| puts " #{File.basename(path)}" } - -print "\nPublish these gems to RubyGems? [y/N] " -answer = $stdin.gets&.strip -abort_with("Release cancelled") unless answer&.casecmp?("y") - -gems.each do |path| - puts "\nPublishing #{File.basename(path)}" - push_gem(path) -end - -puts "\nPublished page_print #{version}"