diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..237414e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AI Agent Playbook + +Repository-specific rules for code-generation agents. Keep changes minimal, +validated, and aligned with this gem's public API. + +## Core Workflow + +- Prefer surgical edits. Do not reformat unrelated code or shuffle files. +- Read actual files, signatures, call sites, and tests before changing code. +- Preserve public APIs unless the requested change requires a contract update. +- Keep the gem reusable; do not add host-app-specific routes, models, or copy. +- Add or update focused tests when behavior changes. +- Do not commit secrets, credentials, tokens, or decrypted values. + +## Release And Upgrade + +- Release changes must preserve existing `CHANGELOG.md` history. +- Use the release task instead of hand-editing version files and tags. +- Changelog pull request references must link to PRs, not issues. +- When a change breaks or changes a public contract, update `UPGRADE.md` in + the same change with explicit host-app migration steps. +- Before finishing release-harness changes, run the focused release tests and + a `git-cliff` smoke check. + +## Commit Messages + +- Use Conventional Commits: `feat`, `fix`, `docs`, `test`, `refactor`, or + `chore`. +- Keep the subject imperative, specific, and under 72 characters. +- Leave a blank line between the subject and body. +- Write one coherent reason per commit; split unrelated work first. +- Use the body when the reasoning matters. Explain why the change exists, + what approach was taken, and what constraints or side effects matter. +- Wrap body lines at 72 characters so commit hooks and terminal tools stay + readable. +- Avoid vague subjects such as `misc fixes`, `updates`, or `cleanup` unless + the cleanup is the actual scoped purpose. +- Mention verification in the body when it materially helps future readers. diff --git a/CHANGELOG.md b/CHANGELOG.md index 449ea34..f7335a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,101 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- avoid rewriting existing IndexNow key files (#11) +- avoid rewriting existing IndexNow key files ([#11](https://github.com/ethos-link/indexmap/pull/11)) + + + +## [0.6.0] - 2026-05-01 + + +### Added + +- add the url count to google ping output ([#9](https://github.com/ethos-link/indexmap/pull/9)) + +- support named sitemap outputs ([#10](https://github.com/ethos-link/indexmap/pull/10)) + + + +## [0.5.0] - 2026-04-24 + + +### Fixed + +- namespace rake tasks and harden sitemap validation ([#8](https://github.com/ethos-link/indexmap/pull/8)) + + + +## [0.4.2] - 2026-04-23 + + +### Fixed + +- harden sitemap pinging and indexnow key handling ([#7](https://github.com/ethos-link/indexmap/pull/7)) + + + +## [0.4.1] - 2026-04-22 + + +### Added + +- add output to ping tasks ([#6](https://github.com/ethos-link/indexmap/pull/6)) + + + +## [0.4.0] - 2026-04-22 + + +### Documentation + +- improve the task messages ([#4](https://github.com/ethos-link/indexmap/pull/4)) + + + +## [0.3.1] - 2026-04-22 + + +### Fixed + +- fix changelog generation ([#2](https://github.com/ethos-link/indexmap/pull/2)) + +- harden indexmap runtime defaults and test coverage ([#3](https://github.com/ethos-link/indexmap/pull/3)) + + + +## [0.3.0] - 2026-04-22 + + +### Added + +- expand indexmap with sitemap parsing, validation, and search engine pinging + + + +## [0.2.1] - 2026-04-21 + + +### Fixed + +- publish built gem in release workflow + + + +## [0.2.0] - 2026-04-21 + + +### Added + +- add single-file sitemap mode + + + +## [0.1.0] - 2026-04-21 + + +### Added + +- bootstrap indexmap gem diff --git a/README.md b/README.md index c4b613f..d48cc90 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,12 @@ Note: `Gemfile.lock` is intentionally not tracked for this gem, following normal ### Git hooks -We use [lefthook](https://lefthook.dev/) with the Ruby [commitlint](https://github.com/arandilopez/commitlint) gem to enforce Conventional Commits on every commit. We also use [Standard Ruby](https://standardrb.com/) to keep code style consistent. CI validates commit messages, Standard Ruby, tests, and git-cliff changelog generation on pull requests and pushes to main/master. +We use [lefthook](https://lefthook.dev/) with the Ruby +[commitlint](https://github.com/arandilopez/commitlint) gem to enforce +Conventional Commits on every commit. We also use +[Standard Ruby](https://standardrb.com/) to keep code style consistent. CI +validates commit messages, Standard Ruby, tests, and git-cliff changelog +generation on pull requests and pushes to main/master. Run the hook installer once per clone: @@ -395,11 +400,16 @@ bundle exec lefthook install ## Release -Releases are tag-driven and published by GitHub Actions to RubyGems. Local release commands never publish directly. +Releases are tag-driven and published by GitHub Actions to RubyGems. +Local release commands never publish directly. -Install [git-cliff](https://git-cliff.org/) locally before preparing a release. The release task regenerates `CHANGELOG.md` from Conventional Commits. +Install [git-cliff](https://git-cliff.org/) locally before preparing a +release. The release task prepends the next `CHANGELOG.md` section from +Conventional Commits. -Before preparing a release, make sure you are on `main` or `master` with a clean worktree. +Before preparing a release, make sure you are on `main` or `master` with a +clean worktree. If the release contains a breaking public-contract change, +update `UPGRADE.md` with the host-app migration steps first. Then run one of: @@ -412,12 +422,13 @@ bundle exec rake 'release:prepare[0.1.0]' The task will: -1. Regenerate `CHANGELOG.md` with `git-cliff`. +1. Prepend the next `CHANGELOG.md` section with `git-cliff`. 1. Update `lib/indexmap/version.rb`. 1. Commit the release changes. 1. Create and push the `vX.Y.Z` tag. -The `Release` workflow then runs tests, publishes the gem to RubyGems, and creates the GitHub release from the changelog entry. +The `Release` workflow then runs tests, publishes the gem to RubyGems, +and creates the GitHub release from the changelog entry. ## License diff --git a/Rakefile b/Rakefile index 3c6626e..47ad012 100644 --- a/Rakefile +++ b/Rakefile @@ -23,14 +23,23 @@ def clean_worktree? system("git diff --quiet") && system("git diff --cached --quiet") end +def release_tag(version) + "v#{version}" +end + def release_version(target) target = target.to_s.strip - raise ArgumentError, "Provide patch, minor, major, or an explicit X.Y.Z version." if target.empty? + if target.empty? + message = "Provide patch, minor, major, or an explicit X.Y.Z version." + raise ArgumentError, message + end return target if target.match?(/\A\d+\.\d+\.\d+\z/) unless VALID_RELEASE_TARGETS.include?(target) - raise ArgumentError, "Invalid release target #{target.inspect}. Use #{VALID_RELEASE_TARGETS.join(", ")} or X.Y.Z." + message = "Invalid release target #{target.inspect}. Use " \ + "#{VALID_RELEASE_TARGETS.join(", ")} or X.Y.Z." + raise ArgumentError, message end major, minor, patch = Indexmap::VERSION.split(".").map(&:to_i) @@ -45,6 +54,50 @@ def release_version(target) end end +def validate_release_version!(version, current) + if Gem::Version.new(version) <= Gem::Version.new(current) + message = "Release version #{version} must be newer than " \ + "current version #{current}." + raise ArgumentError, message + end + + tag = release_tag(version) + if local_release_tag_exists?(tag) + raise ArgumentError, "Release tag #{tag} already exists locally." + end + if remote_release_tag_exists?(tag) + raise ArgumentError, "Release tag #{tag} already exists on origin." + end +end + +def local_release_tag_exists?(tag) + system( + "git", + "rev-parse", + "--quiet", + "--verify", + "refs/tags/#{tag}", + out: File::NULL + ) +end + +def remote_release_tag_exists?(tag) + output = `#{remote_release_tag_command(tag)} 2>&1` + status = $? + + if status.success? + true + elsif status.exitstatus == 2 + false + else + raise "Could not check origin for #{tag}: #{output.strip}" + end +end + +def remote_release_tag_command(tag) + "git ls-remote --exit-code --tags origin refs/tags/#{tag}" +end + def update_version_file(version) File.write( VERSION_PATH, @@ -58,40 +111,71 @@ def update_version_file(version) ) end +def changelog_command(version) + [ + "git-cliff", + "-c", + "cliff.toml", + "--unreleased", + "--tag", + release_tag(version), + "--prepend", + "CHANGELOG.md" + ] +end + def update_changelog(version) - success = system("git-cliff", "-c", "cliff.toml", "--unreleased", "--tag", "v#{version}", "-o", "CHANGELOG.md") - raise "git-cliff failed. Install git-cliff and make sure cliff.toml is valid." unless success - raise "git-cliff did not update CHANGELOG.md. Ensure there are Conventional Commits since the last tag." if system("git", "diff", "--quiet", "--", "CHANGELOG.md") + success = system(*changelog_command(version)) + unless success + message = "git-cliff failed. Install git-cliff and make sure " \ + "cliff.toml is valid." + raise message + end + + if system("git", "diff", "--quiet", "--", "CHANGELOG.md") + message = "git-cliff did not update CHANGELOG.md. Ensure there are " \ + "Conventional Commits since the last tag." + raise message + end end if Rake::Task.task_defined?("release") Rake::Task["release"].clear end -desc "Publishing is handled by GitHub Actions. Use release:prepare[...] instead." +desc "Publishing is handled by CI. Use release:prepare[...] instead." task :release do - abort "Use `bundle exec rake 'release:prepare[patch]'` (or minor/major/X.Y.Z). Publishing runs in GitHub Actions after the tag is pushed." + message = "Use `bundle exec rake 'release:prepare[patch]'` " \ + "(or minor/major/X.Y.Z). Publishing runs in GitHub Actions after " \ + "the tag is pushed." + abort message end namespace :release do - desc "Prepare a release: update CHANGELOG/version, commit, tag, and push. Accepts patch, minor, major, or X.Y.Z." + desc "Prepare a release: update changelog/version, commit, tag, and push." task :prepare, [:target] do |_task, args| branch = current_branch - abort "Release must run on main or master. Current branch: #{branch.inspect}." unless %w[main master].include?(branch) + unless %w[main master].include?(branch) + message = "Release must run on main or master. Current branch: " \ + "#{branch.inspect}." + abort message + end abort "Release requires a clean working tree." unless clean_worktree? version = release_version(args[:target]) current = Indexmap::VERSION - abort "Release version #{version} is older than current version #{current}." if Gem::Version.new(version) < Gem::Version.new(current) + validate_release_version!(version, current) update_changelog(version) update_version_file(version) + tag = release_tag(version) + sh "git add CHANGELOG.md lib/indexmap/version.rb" sh %(git commit -m "chore(release): prepare v#{version}") - sh %(git tag -a v#{version} -m "Release v#{version}") + sh %(git tag -a #{tag} -m "Release #{tag}") sh "git push origin #{branch}" - sh "git push origin v#{version}" + sh "git push origin #{tag}" rescue ArgumentError, RuntimeError => e abort e.message end diff --git a/UPGRADE.md b/UPGRADE.md index 999e895..40f9177 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -77,7 +77,8 @@ Custom storage backends must accept the documented `content_type:` keyword on ```ruby class SitemapStorage def write(filename, body, content_type:) - # Persist body under filename and store content_type when the backend uses it. + # Persist body under filename and store content_type when the backend + # uses it. end def read(filename) diff --git a/cliff.toml b/cliff.toml index 8a40f75..cbae064 100644 --- a/cliff.toml +++ b/cliff.toml @@ -14,6 +14,10 @@ body = """ """ trim = true +[[changelog.postprocessors]] +pattern = '\(#([0-9]+)\)' +replace = '([#${1}](https://github.com/ethos-link/indexmap/pull/${1}))' + [git] conventional_commits = true filter_unconventional = true diff --git a/test/release_task_test.rb b/test/release_task_test.rb new file mode 100644 index 0000000..03ddd62 --- /dev/null +++ b/test/release_task_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "test_helper" +require "rake" + +unless respond_to?(:release_version, true) + load File.expand_path("../Rakefile", __dir__) +end + +class ReleaseTaskTest < Minitest::Test + def test_release_version_increments_patch_from_current_version + major, minor, patch = Indexmap::VERSION.split(".").map(&:to_i) + + assert_equal "#{major}.#{minor}.#{patch + 1}", release_version("patch") + end + + def test_release_version_accepts_explicit_semantic_version + assert_equal "0.3.0", release_version("0.3.0") + end + + def test_validate_release_version_rejects_current_version + error = assert_raises(ArgumentError) do + validate_release_version!("0.2.7", "0.2.7") + end + + assert_equal( + "Release version 0.2.7 must be newer than current version 0.2.7.", + error.message + ) + end + + def test_validate_release_version_rejects_existing_local_tag + @local_release_tag_exists = true + @remote_release_tag_exists = false + + error = assert_raises(ArgumentError) do + validate_release_version!("0.2.8", "0.2.7") + end + + assert_equal "Release tag v0.2.8 already exists locally.", error.message + end + + def test_validate_release_version_rejects_existing_remote_tag + @local_release_tag_exists = false + @remote_release_tag_exists = true + + error = assert_raises(ArgumentError) do + validate_release_version!("0.2.8", "0.2.7") + end + + assert_equal "Release tag v0.2.8 already exists on origin.", error.message + end + + def test_remote_release_tag_command_asks_git_to_fail_when_no_tag_matches + assert_equal( + "git ls-remote --exit-code --tags origin refs/tags/v0.2.8", + remote_release_tag_command("v0.2.8") + ) + end + + def test_changelog_command_prepends_the_next_release + assert_equal( + [ + "git-cliff", + "-c", + "cliff.toml", + "--unreleased", + "--tag", + "v0.2.8", + "--prepend", + "CHANGELOG.md" + ], + changelog_command("0.2.8") + ) + end + + private + + def local_release_tag_exists?(_tag) + @local_release_tag_exists || false + end + + def remote_release_tag_exists?(_tag) + @remote_release_tag_exists || false + end +end