indexmap 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +287 -35
  4. data/UPGRADE.md +361 -0
  5. data/docs/architecture.md +55 -0
  6. data/docs/deployment.md +223 -0
  7. data/docs/domain-terms.md +27 -0
  8. data/docs/dynamic-sitemaps.md +302 -0
  9. data/docs/search-engine-ping.md +368 -0
  10. data/docs/storage.md +94 -0
  11. data/lib/generators/indexmap/install_generator.rb +1 -1
  12. data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
  13. data/lib/indexmap/artifact.rb +9 -0
  14. data/lib/indexmap/configuration.rb +12 -1
  15. data/lib/indexmap/creator.rb +45 -42
  16. data/lib/indexmap/index_now_configuration.rb +3 -1
  17. data/lib/indexmap/output.rb +19 -5
  18. data/lib/indexmap/parser.rb +112 -52
  19. data/lib/indexmap/pinger/base.rb +6 -5
  20. data/lib/indexmap/pinger/google.rb +12 -8
  21. data/lib/indexmap/pinger/index_now.rb +97 -52
  22. data/lib/indexmap/publication.rb +89 -0
  23. data/lib/indexmap/robots.rb +67 -0
  24. data/lib/indexmap/robots_controller.rb +13 -0
  25. data/lib/indexmap/sitemaps_controller.rb +57 -0
  26. data/lib/indexmap/storage/active_storage.rb +132 -1
  27. data/lib/indexmap/storage/filesystem.rb +182 -11
  28. data/lib/indexmap/storage/memory.rb +1 -1
  29. data/lib/indexmap/task_runner.rb +21 -28
  30. data/lib/indexmap/validator/duplicates.rb +83 -0
  31. data/lib/indexmap/validator.rb +171 -59
  32. data/lib/indexmap/version.rb +1 -1
  33. data/lib/indexmap/writer.rb +89 -34
  34. data/lib/indexmap.rb +14 -1
  35. data/lib/tasks/indexmap_tasks.rake +21 -2
  36. data/test/indexmap/correctness_test.rb +153 -0
  37. data/test/indexmap/install_generator_test.rb +36 -4
  38. data/test/indexmap/parser_test.rb +77 -0
  39. data/test/indexmap/pinger/google_test.rb +22 -0
  40. data/test/indexmap/pinger/index_now_test.rb +168 -0
  41. data/test/indexmap/publication_test.rb +280 -0
  42. data/test/indexmap/robots_test.rb +73 -0
  43. data/test/indexmap/sitemaps_controller_test.rb +50 -0
  44. data/test/indexmap/storage_test.rb +27 -0
  45. data/test/indexmap/task_runner_test.rb +24 -0
  46. data/test/indexmap/validator_test.rb +14 -0
  47. data/test/indexmap/writer_test.rb +42 -0
  48. data/test/integration/queue_checks.rb +244 -0
  49. data/test/release_task_test.rb +153 -0
  50. metadata +22 -5
  51. data/lib/indexmap/storage/file.rb +0 -11
@@ -0,0 +1,302 @@
1
+ # Database-backed sitemaps
2
+
3
+ > This guide describes unreleased main. For RubyGems 0.8.2, use the
4
+ > [tagged documentation](https://github.com/ethos-link/indexmap/tree/v0.8.2).
5
+ > See [release status](../README.md#release-status) and [UPGRADE.md](../UPGRADE.md).
6
+
7
+ Indexmap generates sitemap artifacts from app-owned sources. Generation can run
8
+ in a job or rake task; HTTP requests serve published artifacts and never query
9
+ source records. No model concern, queue backend, schedule, or content eligibility
10
+ policy is installed by the gem.
11
+
12
+ ## Configure generation
13
+
14
+ Run `bin/rails generate indexmap:install`, then edit the generated configure block:
15
+
16
+ ```ruby
17
+ config.base_url = -> { ENV.fetch("PUBLIC_ORIGIN") }
18
+ config.storage = Indexmap::Storage::Filesystem.new(
19
+ path: Rails.root.join("storage/sitemaps"), public_url: config.base_url
20
+ )
21
+ config.atomic = true
22
+ config.max_urls = 45_000
23
+ config.sections = -> { Sitemap.sections }
24
+ ```
25
+
26
+ `Sitemap` is the application's manifest. It should return fresh sections whose
27
+ entries are arrays or enumerables. Use native `find_each`/`in_batches`, selected
28
+ columns, and SQL aggregates to select eligible records. Calculate canonical URLs
29
+ with app-owned routing rules. Do not serialize a relation or URL inventory into
30
+ a job, reuse an exhausted source across refreshes, or use refresh time as the
31
+ content's `lastmod`. Time-dependent eligibility needs recurring refreshes even
32
+ when no record is updated. A batch scan is not a point-in-time database snapshot.
33
+
34
+ Generation stages XML on local temporary disk, validates the complete graph, then
35
+ publishes. Entry sources are consumed once. Index mode splits a section before
36
+ `max_urls` (default 50,000) or `max_bytes` (default 52,428,800) is exceeded. Limits
37
+ must be positive integers within the protocol limits. Size includes escaped UTF-8
38
+ XML and document overhead. Locations must be shorter than 2,048 characters.
39
+ A single oversized entry fails; a single-file output
40
+ that needs another partition fails with instructions to use index mode. Empty
41
+ sections are omitted; an empty root fails without replacing the published root.
42
+ The index itself must fit the protocol entry limit and configured byte limit.
43
+
44
+ In legacy mode, the first partition keeps the section filename; later partitions
45
+ append `-002.xml`, `-003.xml`, etc. Atomic mode chooses immutable child filenames
46
+ instead; the configured root filename remains stable. Do not infer eligibility,
47
+ content changes, or section identity from generated child filenames.
48
+
49
+ For filesystem and Active Storage destinations, generation and validation retain
50
+ at most a partition plus bounded parsing/sort buffers in memory, along with
51
+ metadata proportional to the number of sitemap files. Duplicate checking uses
52
+ 10,000-URL sorted runs and a disk merge, including duplicates across partitions.
53
+ Temporary disk use scales with the XML and URL inventory; provision enough local
54
+ disk and monitor it. Validation and IndexNow planning use streaming reads and
55
+ private temporary spools. Submission summaries retain per-batch outcomes and
56
+ failures, so that metadata grows with request count. `Validator#each_entry` yields
57
+ only after the entire inventory validates. `Validator#validate!` collects entries by default for Ruby
58
+ callers; use `collect: false` when the list is not needed.
59
+
60
+ The low-level `Writer#write`, `Parser#entries`, explicit `to_a` calls, memory
61
+ storage, and app-owned arrays can still retain the whole inventory. Use
62
+ `Writer#each_file` or the normal generation operation for incremental output.
63
+ Custom adapters without `open`/metadata or `write_io` fall back to whole-file
64
+ reads/writes; each file is still bounded by the configured limits. There is no
65
+ full-document formatting pass during generation. Task reports use counts recorded
66
+ while writing rather than downloading published files again. Serialization may
67
+ differ from older Nokogiri-reformatted output while retaining XML meaning.
68
+
69
+ ## Atomic publication and storage
70
+
71
+ Atomic mode is opt-in and requires `revision(root)` and
72
+ `publish(source, files:, root:, expected:)` capabilities. An unsupported adapter
73
+ fails before sources are evaluated. See the [complete storage contract](storage.md)
74
+ before implementing a custom adapter. Existing six-method adapters keep legacy
75
+ sequential publication unless explicitly extended. Named outputs inherit atomic
76
+ mode and limits and can override them independently.
77
+
78
+ Both built-in persistent adapters upload immutable children before activating the
79
+ stable root. A root revision captured before generation must still match at
80
+ activation. Otherwise `Indexmap::SupersededPublication` is raised and that build
81
+ cannot overwrite a root published by another writer. Retry by rebuilding current
82
+ eligible data. Queue concurrency reduces wasted work but does not replace this
83
+ check, including for direct rake invocations.
84
+
85
+ Filesystem publication copies into private destination staging without holding
86
+ the per-root lock, including the previous root archive. Short locked sections
87
+ establish a staging lease and activate complete files with same-filesystem
88
+ renames; retirement timestamps are set at activation. Pruning skips live build
89
+ leases and reclaims abandoned staging after the grace period. Exclude hidden
90
+ `.indexmap-builds/` paths from HTTP serving and provision destination staging
91
+ space. The destination must provide cross-process `flock` and atomic rename
92
+ semantics; arbitrary NFS mounts or object-store filesystem gateways may not.
93
+ Storage keys cannot escape through traversal or existing symlinks. Published
94
+ immutable child keys cannot be overwritten through built-in write APIs.
95
+
96
+ For Active Storage, keep the host-owned artifact model with `has_one_attached`
97
+ and a database unique index on its filename column. Install Active Storage through
98
+ Rails' installer and generate any host migration with Rails; inspect and remove
99
+ existing duplicate rows before adding that index. Indexmap does not invent host
100
+ migrations or modify attachment configuration. Example generator command:
101
+
102
+ ```sh
103
+ bin/rails generate migration AddUniqueIndexToSitemapArtifactsFilename
104
+ ```
105
+
106
+ Edit the generated migration to add the unique index after checking existing
107
+ schema and data; do not add a duplicate index if one already exists. Atomic mode
108
+ checks that an unconditional single-column unique index exists. Root blobs carry
109
+ the generation identifier in native Active Storage metadata and are uploaded
110
+ before a short host-record lock attaches them. Expected revisions are checked
111
+ before uploads too; failed or losing builds immediately clean up the child
112
+ records and blobs they created. A race can still waste an in-flight upload, but
113
+ normal failure cleanup does not wait for pruning. Reading the active generation
114
+ requires no object-store download under that lock. Generation and uploads run
115
+ outside that database transaction. SQLite lock contention can raise a
116
+ transient database error; preserve the host's explicit retry policy.
117
+
118
+ The adapter archives the previous atomic sitemap index before activation. Archive ownership
119
+ proves that a retained child's generation was published; uploaded orphans are
120
+ excluded from shared HTTP delivery. Old children remain available for cached
121
+ roots. Active Storage archive age follows the artifact record, not the older
122
+ blob's creation date. The model's normal attachment lifecycle handles purging
123
+ when expired artifact records are destroyed. Ensure its purge jobs have workers;
124
+ use the host's existing unattached-blob cleanup for process crashes. Cleanup
125
+ failures raise `Indexmap::PublicationCleanupError` with failed artifact/key
126
+ diagnostics and the original publication exception as its cause. Preserve those
127
+ private diagnostics: if object deletion fails after a blob row is destroyed,
128
+ cleanup by object key may be necessary. Host callback errors after a committed
129
+ activation do not delete that published generation.
130
+
131
+ Keep the destination shared by workers and every serving instance. A container's
132
+ `public/` directory is not automatically shared or persistent. In legacy mode,
133
+ validation failure preserves old artifacts, but interrupted sequential writes can
134
+ still leave mixed generations. Atomic mode solves that collection-level problem.
135
+
136
+ ## Jobs and schedules
137
+
138
+ Generate the job in the host application, then edit it:
139
+
140
+ ```sh
141
+ bin/rails generate job SitemapRefresh
142
+ ```
143
+
144
+ ```ruby
145
+ require "indexmap"
146
+
147
+ class SitemapRefreshJob < ApplicationJob
148
+ retry_on Indexmap::SupersededPublication, wait: :polynomially_longer, attempts: 3
149
+
150
+ def perform(_version = nil)
151
+ Indexmap::HostConfiguration.apply
152
+ Indexmap::TaskRunner.new.create
153
+ end
154
+ end
155
+ ```
156
+
157
+ Load the host configuration file first if it lives outside an initializer. The
158
+ explicit require above makes the exception class available when a job is
159
+ eager-loaded; it does not evaluate section sources. The host's `ApplicationJob` context,
160
+ authorization, and transaction hooks remain authoritative. Pass scalar output or
161
+ release identifiers and enqueue after source transactions commit and database
162
+ preparation completes. A separate queue database is not transactionally coupled
163
+ to application data.
164
+
165
+ The same body works with GoodJob and Solid Queue. Keep the adapter-specific
166
+ concurrency declarations and recurring schedules in the host; see the
167
+ [GoodJob and Solid Queue examples](../README.md#background-generation). Use a
168
+ shared execution key/label across releases for the same destination. Solid Queue
169
+ uses a shared concurrency group too if multiple job classes write that destination.
170
+ Prefer blocking/rescheduling to discarding a newer refresh. Choose a concurrency
171
+ duration above measured runtime and remember it can expire. Direct `perform_now`
172
+ and rake calls bypass queue coordination but still use atomic activation checks.
173
+
174
+ Use one enqueue owner per trigger. Avoid duplicate startup/release scheduling,
175
+ per-country fan-out, and continuations until actual runtime justifies them. Retry
176
+ identified transient storage/database errors deliberately; do not repeatedly retry
177
+ invalid configuration or content. `after_create` callbacks run after publication
178
+ and must be safe to repeat. A callback failure does not roll publication back.
179
+
180
+ `TaskRunner#create` runs default-output callbacks and key-file handling.
181
+ `Indexmap.create(:name)` generates a named output without those callbacks. Keep
182
+ that distinction when designing independent refresh jobs. Generation does not
183
+ submit to IndexNow; keep sending as a separate operation so refresh retries do
184
+ not resend successful batches.
185
+
186
+ ## Serve published XML
187
+
188
+ Require the controller and explicitly mount the root and generated child pattern.
189
+ This example assumes root-level storage keys and `index_filename = "sitemap.xml"`:
190
+
191
+ ```ruby
192
+ require "indexmap/sitemaps_controller"
193
+ Indexmap::HostConfiguration.apply
194
+
195
+ Rails.application.routes.draw do
196
+ get "/sitemap.xml", to: Indexmap::SitemapsController.action(:show),
197
+ defaults: {filename: "sitemap.xml"}, format: false
198
+ get "/:filename", to: Indexmap::SitemapsController.action(:show),
199
+ constraints: {filename: /sitemap-[a-f0-9]{16}-[a-f0-9]{32}-[1-9]\d*-[1-9]\d*\.xml/},
200
+ format: false
201
+ end
202
+ ```
203
+
204
+ Mount named roots explicitly too. For nested keys, set the full root key in the
205
+ route defaults and a fixed `prefix` default for child routes. For example a
206
+ `/sitemaps/:filename` child route can use `defaults: {prefix: "sitemaps"}`. Only
207
+ path parameters are used, so query strings cannot supply a storage prefix. Dots
208
+ must be accepted by the filename constraint; disabling format parsing alone does
209
+ not make Rails dynamic segments accept dots. Do not mount a broad catchall route.
210
+
211
+ The controller allows configured roots and existing children from current or
212
+ archived atomic publications. It never resolves section providers. Other files,
213
+ archive indexes, and unpublished orphan children are excluded. Delivery requires
214
+ storage `metadata` and `open` capabilities, implemented by both persistent
215
+ adapters. The endpoint uses `application/xml`, streams bodies, and supports HEAD,
216
+ ETag, Last-Modified, and 304. Roots cache for ten minutes with one hour of stale
217
+ revalidation; immutable children cache for a year. Generation authorization uses
218
+ the host's Rails cache keyed by the current root's metadata. Authentication,
219
+ visitor tracking, and browser gates from host controllers are not inherited.
220
+
221
+ When switching from legacy child names, keep the old allowlisted child routes
222
+ or static files through the cached-root/rollback grace period. Atomic mode does
223
+ not rename or prune those legacy files automatically, and the new shared
224
+ controller does not authorize arbitrary legacy children. Add the generated child
225
+ route first, enable atomic publication, and retire legacy serving only after the
226
+ transition period. Then remove the old child artifacts deliberately.
227
+
228
+ Remove static files that would shadow these routes, or deliberately keep static
229
+ serving and configure its root/child cache headers appropriately. Robots is a
230
+ separate opt-in endpoint; see [dynamic robots](../README.md#dynamic-robots).
231
+ Public origins must come from configuration, not request headers. Storage public
232
+ URLs must be the URLs that the app actually serves, not private object-store URLs.
233
+
234
+ ## Retention and operations
235
+
236
+ ```sh
237
+ bin/rails indexmap:sitemap:create
238
+ bin/rails indexmap:sitemap:validate
239
+ RETENTION_HOURS=48 bin/rails indexmap:sitemap:prune
240
+ ```
241
+
242
+ Schedule pruning separately from generation. The default grace period is 48 hours;
243
+ values below one hour are rejected. Choose a period longer than the oldest root
244
+ that your caches can still serve, including stale revalidation and deployment
245
+ rollbacks. Retention is an operational policy, not a promise about arbitrary
246
+ crawler caches. Pruning is locked against activation, preserves the current
247
+ publication, removes expired archives and unreferenced owned children, and leaves
248
+ other outputs and unrelated files alone. Abandoned destination staging and
249
+ crash-left orphans age out too. Live filesystem staging remains protected by its
250
+ build lock. Active Storage activation rechecks child existence, so pruning an
251
+ old in-flight build forces a rebuild instead of activating an incomplete root.
252
+ Active Storage pruning destroys owned artifact records and delegates blob cleanup
253
+ to normal attachment jobs, avoiding object-store deletes under the root lock.
254
+
255
+ Do not use an old app callback that deletes everything outside configured section
256
+ filenames: atomic child names deliberately differ. Replace it with the gem's
257
+ prune task when adopting atomic mode. The format task refuses to run when an
258
+ atomic output is configured. Regenerate instead of mutating immutable XML.
259
+
260
+ For IndexNow selection, exact attempt exports, timeouts, and bounded retries, see
261
+ [search-engine submission](search-engine-ping.md#ping-indexnow). A missing timestamp
262
+ is not evidence of a content change, and removal from a sitemap is not evidence
263
+ of deletion. Durable history and automatic inventory diffs are not implemented.
264
+
265
+ ## Host adoption
266
+
267
+ Release and consume the gem before removing host implementations. Local path
268
+ overrides are for verification only and must not be committed to applications.
269
+
270
+ 1. Keep eligibility scopes, canonical URLs, content dates, and scheduling in the
271
+ host. Static manifests can stay database-free; database sources should return
272
+ fresh lazy enumerables.
273
+ 2. Choose storage shared by refresh workers and serving instances. Verify the
274
+ artifact filename index when using atomic Active Storage.
275
+ 3. Add explicit root and generated-child routes, or configure static/CDN delivery.
276
+ Preserve legacy child delivery through the cached-root transition.
277
+ 4. Replace filename-based cleanup callbacks with output-scoped pruning. Preserve
278
+ the host's robots exclusions and production/preview indexing policy.
279
+ 5. Verify the lifecycle in development, then staging, before enabling public
280
+ indexing and search-engine submission. See the [deployment guide](deployment.md).
281
+
282
+ Validate actual public root/child URLs, headers, key verification files, workers,
283
+ and queue failure reporting in each host. Gem integration tests exercise real
284
+ GoodJob, Solid Queue, and Active Storage against PostgreSQL; they do not prove a
285
+ particular application's deployment wiring.
286
+
287
+ ## Integration checks from a source checkout
288
+
289
+ The integration harness creates a temporary Rails app and runs framework/gem
290
+ installers and generators. It drops and recreates the supplied `indexmap_test`
291
+ database. Never point it at an application database.
292
+
293
+ ```sh
294
+ BUNDLE_GEMFILE=gemfiles/integration.gemfile bundle install
295
+ INDEXMAP_DATABASE_URL=postgresql://postgres@localhost/indexmap_test \
296
+ BUNDLE_GEMFILE=gemfiles/integration.gemfile bundle exec ruby script/integration
297
+ ```
298
+
299
+ The harness checks the real GoodJob adapter, a running Solid Queue worker,
300
+ validation failure visibility, shared filesystem output, Active Storage activation,
301
+ and archive retention. CI provides a disposable PostgreSQL service. No external
302
+ search-engine requests are made.
@@ -0,0 +1,368 @@
1
+ # Search Engine Ping
2
+
3
+ > This guide describes unreleased main. For RubyGems 0.8.2, use the
4
+ > [tagged documentation](https://github.com/ethos-link/indexmap/tree/v0.8.2).
5
+ > See [release status](../README.md#release-status) and [UPGRADE.md](../UPGRADE.md).
6
+
7
+ `indexmap` can submit generated sitemap data to Google Search Console and
8
+ IndexNow. These integrations are optional: sitemap generation, formatting, and
9
+ validation work without any search-engine credentials.
10
+
11
+ Available rake tasks:
12
+
13
+ ```bash
14
+ bin/rails indexmap:sitemap:validate
15
+ bin/rails indexmap:google:ping
16
+ bin/rails indexmap:index_now:ping
17
+ bin/rails indexmap:index_now:write_key
18
+ bin/rails indexmap:ping
19
+ ```
20
+
21
+ `indexmap:ping` runs both IndexNow and Google submission. Run
22
+ `indexmap:sitemap:create` before pinging so the configured storage contains the
23
+ current sitemap files.
24
+
25
+ The configured `index_filename` is the authoritative sitemap entrypoint.
26
+ Google submits that sitemap URL once. IndexNow recursively reads its referenced
27
+ child sitemaps and submits their page URLs. Other sitemap-shaped files left in
28
+ storage are ignored unless the authoritative sitemap references them.
29
+
30
+ ## Google Search Console
31
+
32
+ Google submission uses the Search Console Sitemaps API. It submits sitemap URLs
33
+ such as `https://example.com/sitemap.xml` to a verified Search Console
34
+ property. It does not submit arbitrary page URLs to Google for immediate
35
+ indexing.
36
+
37
+ Current Google API contract:
38
+
39
+ - API: Search Console Sitemaps API
40
+ - Method: `PUT https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}`
41
+ - OAuth scope: `https://www.googleapis.com/auth/webmasters`
42
+ - Request body: empty
43
+ - Success response body: empty
44
+
45
+ The Google Indexing API is a separate page-level API at
46
+ `POST https://indexing.googleapis.com/v3/urlNotifications:publish` with the
47
+ `https://www.googleapis.com/auth/indexing` scope. Google documents that API for
48
+ JobPosting pages and livestreaming events with `BroadcastEvent` structured data,
49
+ not for general sitemap submission. `indexmap` therefore uses the Search Console
50
+ Sitemaps API for Google.
51
+
52
+ ### Create The Google Credentials
53
+
54
+ 1. Open or create a Google Cloud project for the site.
55
+ 2. Enable the Google Search Console API for that project.
56
+ 3. Create a service account for sitemap submission.
57
+ 4. Create a JSON key for that service account.
58
+ 5. Store the downloaded JSON as a secret in the host app. Do not commit it.
59
+ 6. Copy the JSON key's `client_email`.
60
+ 7. Open the Search Console property for the site.
61
+ 8. Go to `Settings` > `Users and permissions`.
62
+ 9. Add the service account `client_email` as a user with permission to submit
63
+ sitemaps. `Full` user access is sufficient for sitemap submission; `Owner`
64
+ also works but grants broader access.
65
+
66
+ The Search Console property must match what `indexmap` submits. Domain
67
+ properties use `sc-domain:example.com`; URL-prefix properties use the exact URL
68
+ prefix shown in Search Console, such as `https://www.example.com/`.
69
+
70
+ ### Configure Indexmap
71
+
72
+ `config.google.credentials` must return the JSON key contents, not a path to a
73
+ file.
74
+
75
+ Rails credentials example:
76
+
77
+ ```yaml
78
+ google:
79
+ sitemap: |
80
+ {
81
+ "type": "service_account",
82
+ "project_id": "example-project",
83
+ "private_key_id": "...",
84
+ "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
85
+ "client_email": "indexmap-sitemap@example-project.iam.gserviceaccount.com",
86
+ "client_id": "...",
87
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
88
+ "token_uri": "https://oauth2.googleapis.com/token",
89
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
90
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/indexmap-sitemap%40example-project.iam.gserviceaccount.com"
91
+ }
92
+ ```
93
+
94
+ Initializer example:
95
+
96
+ ```ruby
97
+ Indexmap.configure do |config|
98
+ config.google.credentials = -> { Rails.application.credentials.dig(:google, :sitemap) }
99
+ config.google.property = -> { "sc-domain:example.com" }
100
+ end
101
+ ```
102
+
103
+ Environment variable example:
104
+
105
+ ```ruby
106
+ Indexmap.configure do |config|
107
+ config.google.credentials = -> { ENV["GOOGLE_SITEMAP_JSON"] }
108
+ config.google.property = -> { ENV.fetch("GOOGLE_SEARCH_CONSOLE_PROPERTY", "sc-domain:example.com") }
109
+ end
110
+ ```
111
+
112
+ If `config.google.property` is not set, `indexmap` defaults to
113
+ `sc-domain:<host>` from `config.base_url`.
114
+
115
+ Run:
116
+
117
+ ```bash
118
+ bin/rails indexmap:sitemap:create
119
+ bin/rails indexmap:sitemap:validate
120
+ bin/rails indexmap:google:ping
121
+ ```
122
+
123
+ A successful ping prints how many sitemap files and URLs were submitted. If the
124
+ task says Search Console does not have access, compare the configured property
125
+ identifier and the service account `client_email` against the Search Console
126
+ property's `Users and permissions` page.
127
+
128
+ ## IndexNow
129
+
130
+ IndexNow submission sends changed URL lists to the IndexNow endpoint. It is a
131
+ separate protocol from Google Search Console submission.
132
+
133
+ IndexNow requires a key that proves control of the submitted host. `indexmap`
134
+ uses a strict key format: 32 lowercase hexadecimal characters. The verification
135
+ file must be named `<key>.txt` unless `config.index_now.key_filename` overrides
136
+ the filename, and its contents must be exactly the key.
137
+
138
+ ### File-Based Key Provisioning
139
+
140
+ The simplest flow is to let `indexmap` generate or reuse the key file:
141
+
142
+ ```bash
143
+ bin/rails indexmap:index_now:write_key
144
+ ```
145
+
146
+ That task:
147
+
148
+ - reuses an existing valid `<key>.txt` file from the configured storage
149
+ - otherwise generates a new 32-character hexadecimal key
150
+ - writes `<key>.txt` with the key as the file contents
151
+ - makes that key available to `indexmap:index_now:ping`
152
+
153
+ After deployment, confirm the key file is publicly reachable:
154
+
155
+ ```bash
156
+ curl -fsS https://example.com/<key>.txt
157
+ ```
158
+
159
+ The response body should be the key only, with no extra text.
160
+
161
+ Key discovery only searches `config.storage`. For example, when sitemap storage
162
+ points at `storage/sitemaps`, a verification file in Rails' `public/` directory
163
+ will not be discovered. Use the configuration below for that arrangement.
164
+
165
+ ### Configured Key
166
+
167
+ If the host app already stores an IndexNow key in credentials or environment
168
+ variables, configure it directly:
169
+
170
+ ```ruby
171
+ Indexmap.configure do |config|
172
+ config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
173
+ end
174
+ ```
175
+
176
+ When `config.index_now.key` is set, `indexmap:sitemap:create` also ensures the
177
+ matching `<key>.txt` verification file exists in the configured storage. It
178
+ leaves an existing valid key file unchanged.
179
+
180
+ If the verification file uses a non-standard name, configure it explicitly:
181
+
182
+ ```ruby
183
+ Indexmap.configure do |config|
184
+ config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
185
+ config.index_now.key_filename = -> { ENV.fetch("INDEXNOW_KEY") + ".txt" }
186
+ end
187
+ ```
188
+
189
+ If another part of the host app owns the verification file, disable automatic
190
+ key-file writes:
191
+
192
+ ```ruby
193
+ Indexmap.configure do |config|
194
+ config.index_now.key = -> { ENV["INDEXNOW_KEY"] }
195
+ config.index_now.write_key_file = false
196
+ end
197
+ ```
198
+
199
+ ### Rails Public Key File With Separate Sitemap Storage
200
+
201
+ If Rails serves an existing verification file from `public/` while sitemaps
202
+ live in `storage/sitemaps` or another backend, configure the key explicitly and
203
+ keep the host app responsible for serving the file:
204
+
205
+ ```ruby
206
+ Indexmap.configure do |config|
207
+ config.base_url = -> { "https://www.example.com" }
208
+ config.storage = lambda {
209
+ Indexmap::Storage::Filesystem.new(
210
+ path: Rails.root.join("storage/sitemaps"),
211
+ public_url: config.base_url
212
+ )
213
+ }
214
+ config.index_now.key = -> {
215
+ Rails.public_path.join("0123456789abcdef0123456789abcdef.txt").read.strip
216
+ }
217
+ config.index_now.write_key_file = false
218
+ end
219
+ ```
220
+
221
+ Replace the example filename with your existing `<key>.txt` filename. For a new
222
+ key, generate one with `ruby -rsecurerandom -e 'print SecureRandom.hex(16)'`, then
223
+ create `public/<key>.txt` containing exactly that key, without a trailing newline.
224
+ Deploy the file with the application.
225
+
226
+ `write_key_file = false` prevents sitemap generation from writing a second key
227
+ file into sitemap storage. It does not change where the pinger searches for a
228
+ key, so `config.index_now.key` must also be set.
229
+
230
+ The pinger builds `keyLocation` using
231
+ `config.storage.public_url(config.index_now.key_filename)`. In this example,
232
+ that URL is `https://www.example.com/<key>.txt`. Ensure the storage adapter
233
+ returns the actual public verification URL; adapters with a different public
234
+ origin or path need a matching publicly served file there.
235
+
236
+ The host app must also serve the sitemap files at their configured public URLs.
237
+ A filesystem adapter writes files but does not create Rails routes for them.
238
+
239
+ Before submitting URLs, verify the deployed file and run a dry run in the
240
+ production environment:
241
+
242
+ ```bash
243
+ curl -fsS https://www.example.com/<key>.txt
244
+ RAILS_ENV=production bin/rails indexmap:sitemap:create
245
+ RAILS_ENV=production bin/rails indexmap:sitemap:validate
246
+ RAILS_ENV=production INDEXNOW_DRY_RUN=1 bin/rails indexmap:index_now:ping
247
+ ```
248
+
249
+ The curl response must contain exactly the configured key. A dry run checks
250
+ local key loading and sitemap parsing; it does not verify that IndexNow can
251
+ fetch the public file. After those checks, run
252
+ `RAILS_ENV=production bin/rails indexmap:index_now:ping` to submit the URLs.
253
+
254
+ ### Ping IndexNow
255
+
256
+ #### Inspect selection and actual attempts
257
+
258
+ Preview the exact selection without submitting or requiring a key:
259
+
260
+ ```bash
261
+ SINCE=2026-04-18T10:30:00Z bin/rails indexmap:index_now:plan
262
+ ```
263
+
264
+ The task writes one JSON object per URL, including excluded URLs. Each row has
265
+ `loc`, `lastmod`, `source_sitemap`, `index_filename`, the resolved UTC `cutoff`,
266
+ `selected`, `reason`, the selected `batch` number, and `missing_key`. Reasons are
267
+ `all`, `recent`, `missing_lastmod`, and `before_cutoff`. An empty root discovery
268
+ produces no rows. Invalid reachable sitemaps raise before selection or sending.
269
+ Planning reads configured storage only; it makes no network requests or writes.
270
+
271
+ To record which URLs a real submission attempts, use:
272
+
273
+ ```bash
274
+ INDEXNOW_REPORT=1 bin/rails indexmap:index_now:ping > /private/operator-path/indexnow-attempts.jsonl
275
+ ```
276
+
277
+ Use an existing private destination outside `public/` and sitemap storage.
278
+ The report replaces the task's human-readable output with JSON Lines:
279
+ `attempt_started` contains the batch number and exact `urls`; `attempt_finished`
280
+ adds its status and HTTP receipt; `summary` retains the normal result totals.
281
+ HTTP 202 has receipt `validation_pending`; other successful HTTP responses have
282
+ receipt `received`. Neither proves crawling or indexing. Dry runs emit only a
283
+ summary because they make no attempts.
284
+
285
+ Timeouts and connection failures produce a failed batch with reason
286
+ `unknown_delivery`. Earlier successful batches remain in the summary. There are no
287
+ automatic retries by default. Requests use a five-second connection timeout and
288
+ a 20-second request timeout. API keys are omitted from reports.
289
+
290
+ Enable bounded retries explicitly in the existing configure block:
291
+
292
+ ```ruby
293
+ config.index_now.max_attempts = 3
294
+ config.index_now.max_wait = 30
295
+ ```
296
+
297
+ Attempts must be an integer from 1 through 5; `max_wait` is a finite number of
298
+ seconds from 0 through 60. The default is one attempt and a 30-second wait budget.
299
+ Only the affected batch retries for transport uncertainty or HTTP 429, 500, 502,
300
+ 503, and 504. Backoff includes jitter. A valid `Retry-After` takes precedence;
301
+ when it exceeds `max_wait`, the batch remains failed instead of retrying early.
302
+ Permanent errors and HTTP 202 do not retry. Both report events include an
303
+ `attempt` number, so an initially uncertain attempt remains visible even when a
304
+ later attempt succeeds. Summary counts describe final batch outcomes.
305
+
306
+ Do not stack whole-job retries around a partially successful submission: that can
307
+ resend earlier successful batches. Retries do not survive process restarts and
308
+ may duplicate a remotely received request after a timeout. Generation and
309
+ submission remain separate. Preview and sending validate and spool the complete
310
+ reachable inventory before yielding selection or issuing requests; selected URLs
311
+ are then read in bounded batches instead of retaining an inventory-sized array.
312
+
313
+ Exports are not a durable ledger. A preview and a later submission can differ
314
+ when files or configuration change. An attempt-started record proves local
315
+ intent, not remote receipt; a crash or timeout can leave delivery uncertain.
316
+ Retain reports yourself if historical visibility is required.
317
+
318
+ Ruby callers can use `pinger.plan.each` for selection rows and
319
+ `pinger.ping { |event| ... }` for attempt events without retaining another URL
320
+ inventory. Exceptions raised by the reporting block propagate to the caller.
321
+
322
+ #### Submit selected URLs
323
+
324
+ Run:
325
+
326
+ ```bash
327
+ bin/rails indexmap:sitemap:create
328
+ bin/rails indexmap:sitemap:validate
329
+ bin/rails indexmap:index_now:ping
330
+ ```
331
+
332
+ For a dry run:
333
+
334
+ ```bash
335
+ INDEXNOW_DRY_RUN=1 bin/rails indexmap:index_now:ping
336
+ ```
337
+
338
+ By default, `indexmap:index_now:ping` submits all current sitemap URLs. To limit
339
+ the submission to recently changed sitemap entries, set one of:
340
+
341
+ ```bash
342
+ SINCE=2026-04-18T10:30:00Z bin/rails indexmap:index_now:ping
343
+ INDEXNOW_RECENT_HOURS=24 bin/rails indexmap:index_now:ping
344
+ ```
345
+
346
+ `SINCE` takes precedence; invalid values raise rather than falling back. Date-only
347
+ `lastmod` values are compared to the cutoff's UTC calendar date, including that
348
+ whole day. Missing dates are always included, so this filter is not a precise
349
+ content-change detector. Batch sizes default to 500 and must be integers from
350
+ 1 through 10,000, configured with `config.index_now.max_urls_per_request` or
351
+ `INDEXNOW_MAX_URLS_PER_REQUEST`.
352
+
353
+ If the task says the key is missing, either configure `config.index_now.key` or
354
+ run `indexmap:index_now:write_key` and deploy the generated key file. If a key
355
+ file already exists in `public/`, check whether `config.storage` points at a
356
+ different directory; use the separate-storage example above in that case. If the
357
+ remote endpoint returns `403`, verify that the public key file URL returns the
358
+ same key that `indexmap` is submitting.
359
+
360
+ ## References
361
+
362
+ - [Google Search Console Sitemaps API](https://developers.google.com/webmaster-tools/v1/sitemaps/submit)
363
+ - [Google Search Console API reference](https://developers.google.com/webmaster-tools/v1/api_reference_index)
364
+ - [Google Indexing API urlNotifications reference](https://developers.google.com/search/apis/indexing-api/v3/reference/indexing/rest/v3/urlNotifications/)
365
+ - [Google Indexing API usage limits](https://developers.google.com/search/apis/indexing-api/v3/using-api)
366
+ - [Google Cloud service account keys](https://cloud.google.com/iam/docs/keys-create-delete)
367
+ - [Search Console users and permissions](https://support.google.com/webmasters/answer/7687615)
368
+ - [IndexNow protocol documentation](https://www.indexnow.org/documentation)