duckling 0.4.7-aarch64-linux
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.
- checksums.yaml +7 -0
- data/Brewfile +3 -0
- data/CHANGELOG.md +218 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/NOTICES +52 -0
- data/README.md +253 -0
- data/Rakefile +243 -0
- data/docs/2026-07-01-roadmap.md +106 -0
- data/docs/tz-database-axis.md +454 -0
- data/lib/duckling/3.2/duckling.so +0 -0
- data/lib/duckling/3.3/duckling.so +0 -0
- data/lib/duckling/3.4/duckling.so +0 -0
- data/lib/duckling/4.0/duckling.so +0 -0
- data/lib/duckling/tzinfo_capabilities.rb +59 -0
- data/lib/duckling/version.rb +5 -0
- data/lib/duckling.rb +284 -0
- metadata +121 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# The tz-database axis
|
|
2
|
+
|
|
3
|
+
This document is the central reference for time zone (tz) data in this gem.
|
|
4
|
+
It replaces long inline comments. Keep inline comments short. Point here
|
|
5
|
+
instead.
|
|
6
|
+
|
|
7
|
+
## Why the tz database is an axis
|
|
8
|
+
|
|
9
|
+
`reference_zone:` resolves zones with the `tzinfo` gem. tzinfo uses the
|
|
10
|
+
`tzinfo-data` gem when that gem is installed. Without that gem, tzinfo uses
|
|
11
|
+
the zoneinfo files of the host. This gem does not depend on `tzinfo-data`.
|
|
12
|
+
Both configurations are valid production configurations.
|
|
13
|
+
|
|
14
|
+
The two databases do not give the same answers. They differ on three
|
|
15
|
+
independent axes:
|
|
16
|
+
|
|
17
|
+
- **Modelling.** Some distributions compile tzdata in rearguard format.
|
|
18
|
+
Rearguard data strips negative DST. Example: `Europe/Dublin` is a
|
|
19
|
+
negative-DST zone in vanguard data. It is an ordinary positive-DST zone in
|
|
20
|
+
rearguard data. Ubuntu 24.04 and macOS ship rearguard data. Debian trixie,
|
|
21
|
+
Alpine, and FreeBSD ship vanguard data.
|
|
22
|
+
- **Backward-compatibility links.** Debian and Ubuntu move the links to a
|
|
23
|
+
separate `tzdata-legacy` package. That package is not installed by
|
|
24
|
+
default. `US/Eastern` and approximately 100 other names then stop
|
|
25
|
+
resolving. The identifier count drops from approximately 600 to
|
|
26
|
+
approximately 500.
|
|
27
|
+
- **Vintage.** A pinned `tzinfo-data` gem or an unpatched host can predate a
|
|
28
|
+
rule change. Example: `America/Nuuk` got new rules in tzdata 2023a. A
|
|
29
|
+
2021–2022 vintage resolves the zone and answers with the old rules. Before
|
|
30
|
+
2020a the name `America/Nuuk` does not exist at all.
|
|
31
|
+
|
|
32
|
+
A host can also have no tz database at all. Scratch and distroless
|
|
33
|
+
containers are examples. There `reference_zone:` raises
|
|
34
|
+
`Duckling::TZDataUnavailable`.
|
|
35
|
+
|
|
36
|
+
A suite run cannot observe which database it ran against. It passes
|
|
37
|
+
identically on both. Four mechanisms keep the coverage honest: environments,
|
|
38
|
+
probes, capability-gated tests, and environment contracts.
|
|
39
|
+
|
|
40
|
+
## Mechanism 1: Environments
|
|
41
|
+
|
|
42
|
+
Two environment variables select the database under test:
|
|
43
|
+
|
|
44
|
+
- `DUCKLING_TZINFO_DATA` (read in the Gemfile) controls the `tzinfo-data`
|
|
45
|
+
gem in the bundle:
|
|
46
|
+
- unset: the current release of the gem. This is the default environment.
|
|
47
|
+
- `none`: no gem. tzinfo falls back to the zoneinfo files of the host.
|
|
48
|
+
- an exact version, for example `1.2022.7`: a stale database.
|
|
49
|
+
- `DUCKLING_ZONEINFO_DIR` (read in `test/test_helper.rb`) points tzinfo at a
|
|
50
|
+
specific compiled zoneinfo directory. It is set before `duckling` is
|
|
51
|
+
required. Nothing then resolves a zone against the default source first.
|
|
52
|
+
|
|
53
|
+
Always set `BUNDLE_LOCKFILE` for any environment except the default.
|
|
54
|
+
Without it, `bundle install` overwrites the committed `Gemfile.lock`. The
|
|
55
|
+
per-environment lockfiles are gitignored (`/Gemfile.*.lock`). A dirty tree
|
|
56
|
+
also blocks `rake release` and `rake benchmark:record_pr`. Both are guarded
|
|
57
|
+
by `release:guard_clean`. The error looks unrelated.
|
|
58
|
+
|
|
59
|
+
An unrecognized `DUCKLING_TZINFO_DATA` value raises in the Gemfile. A typo
|
|
60
|
+
must fail there. It must not fail deep in the resolver as an unsatisfiable
|
|
61
|
+
constraint.
|
|
62
|
+
|
|
63
|
+
Seven environments run in CI:
|
|
64
|
+
|
|
65
|
+
| Environment | CI job | Database under test |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| default | `baseline` | current `tzinfo-data` gem |
|
|
68
|
+
| system-zoneinfo | `baseline` step | zoneinfo files of the runner |
|
|
69
|
+
| linkless-zoneinfo | `baseline` step | built by `bin/build-linkless-zoneinfo` |
|
|
70
|
+
| tzinfo-data 1.2022.7 | `timezones` matrix | pinned gem |
|
|
71
|
+
| stale system zoneinfo | `timezones` matrix | built by `bin/build-stale-zoneinfo` |
|
|
72
|
+
| Debian + tzdata-legacy | `tz-containers` matrix | system zoneinfo with the links |
|
|
73
|
+
| Alpine | `tz-containers` matrix | vanguard zoneinfo, musl source build |
|
|
74
|
+
|
|
75
|
+
Gating:
|
|
76
|
+
|
|
77
|
+
- Only `baseline` blocks a merge. It is the only required check. So the
|
|
78
|
+
three environments a consumer is actually on run there as steps.
|
|
79
|
+
- `timezones` and `tz-containers` do not block a merge. They do block a
|
|
80
|
+
release. `release.yml` waits on the full workflow (`needs: ci`). A red
|
|
81
|
+
result there means "this vintage answers differently". It does not mean
|
|
82
|
+
"the gem is broken for anyone today".
|
|
83
|
+
|
|
84
|
+
The linkless environment must be built. No runner is in that state.
|
|
85
|
+
`ubuntu-latest` resolves `US/Eastern` from its own tzdata. macOS does too.
|
|
86
|
+
Note: the runner is not a stock Ubuntu for tz data. A plain `ubuntu:24.04`
|
|
87
|
+
container of an earlier tzdata point release does not resolve the links.
|
|
88
|
+
The runner does. So no runner fact settles the links. The suite probes them.
|
|
89
|
+
|
|
90
|
+
One configuration deliberately has no environment: a host with no tz
|
|
91
|
+
database at all. A runner without tz data would break more than this gem.
|
|
92
|
+
Every probe answers `false` there. So the suite loads and runs. The
|
|
93
|
+
`DataSourceNotFound` arms assert this. `test/gem/installed_gem_test.rb`
|
|
94
|
+
skips its `reference_zone:` case there for the same reason.
|
|
95
|
+
|
|
96
|
+
## Mechanism 2: Behavioral probes
|
|
97
|
+
|
|
98
|
+
Neither datasource exposes a version. `RubyDataSource` keeps `version_info`
|
|
99
|
+
private. `ZoneinfoDataSource` exposes only `zoneinfo_dir`. So a probe asks
|
|
100
|
+
the database a question. It does not read a release string.
|
|
101
|
+
|
|
102
|
+
Probes are split by consumer:
|
|
103
|
+
|
|
104
|
+
- `Duckling::TZInfoCapabilities` (`lib/duckling/tzinfo_capabilities.rb`)
|
|
105
|
+
ships in the gem. It holds only what the unknown-identifier error message
|
|
106
|
+
needs.
|
|
107
|
+
- `TZCapabilities` (`test/support/tz_capabilities.rb`) holds the probes that
|
|
108
|
+
only the suite calls. `lib/` reaches every consumer. `test/` reaches
|
|
109
|
+
none. A probe with no production caller does not belong in `lib/`.
|
|
110
|
+
|
|
111
|
+
`backward_compat_links?` is needed on both sides. Production owns it. The
|
|
112
|
+
test module delegates to it.
|
|
113
|
+
|
|
114
|
+
Probe rules:
|
|
115
|
+
|
|
116
|
+
- A probe is a total boolean. It answers `false` on a host with no database.
|
|
117
|
+
It must not raise. `TZInfo::DataSourceNotFound` is a sibling of
|
|
118
|
+
`InvalidTimezoneIdentifier`. It is not a subclass. Rescue it explicitly.
|
|
119
|
+
- Nothing is memoized. `TZInfo::DataSource.set` can swap the database
|
|
120
|
+
mid-process. The fixture-zone tests do this. A cached answer would
|
|
121
|
+
describe a database that is no longer in use.
|
|
122
|
+
- `TZCapabilities.supports?` raises `ArgumentError` on an unknown capability
|
|
123
|
+
name. A typo must be a hard error. It must not be a silent `false`.
|
|
124
|
+
|
|
125
|
+
The three probes:
|
|
126
|
+
|
|
127
|
+
- `models_negative_dst?` asks about `Europe/Dublin`. IANA models it as
|
|
128
|
+
+01:00 standard all year, with a negative one-hour saving in winter. So
|
|
129
|
+
tzinfo reports January as the `dst?` period. Rearguard data re-expresses
|
|
130
|
+
the same offsets as ordinary positive DST.
|
|
131
|
+
- `backward_compat_links?` asks for `US/Eastern`. It is a link to
|
|
132
|
+
`America/New_York` in IANA's `backward` file.
|
|
133
|
+
- `greenland_2023_rules?` asks whether `America/Nuuk` skips
|
|
134
|
+
2026-03-28 23:30. Older vintages fail in two ways. Before 2020a the name
|
|
135
|
+
does not exist. The 2021–2022 vintages know the name but answer with the
|
|
136
|
+
old rules. The zone resolves and gives a different answer. That is the
|
|
137
|
+
harder failure to attribute.
|
|
138
|
+
|
|
139
|
+
## Mechanism 3: Capability-gated tests
|
|
140
|
+
|
|
141
|
+
A test whose premise is a capability lives in
|
|
142
|
+
`test/capabilities/<capability>_test.rb`. The loader at the bottom of
|
|
143
|
+
`test/test_helper.rb` loads the file only where the probe passes. On a
|
|
144
|
+
database that cannot answer, the test is not in the run. It does not fail
|
|
145
|
+
for want of the capability. It does not pass vacuously.
|
|
146
|
+
|
|
147
|
+
The filename is the declaration. The loader calls `supports?` with the
|
|
148
|
+
filename. An unknown name raises at load time.
|
|
149
|
+
|
|
150
|
+
Rules:
|
|
151
|
+
|
|
152
|
+
- A test whose weak mode is a vacuous pass must assert its own premise.
|
|
153
|
+
Example: `negative_dst_test.rb` asserts `models_negative_dst?` in the
|
|
154
|
+
test body. The probe gates the load. The assertion catches the day the
|
|
155
|
+
probe or the IANA data drifts. The test uses the same predicate as the
|
|
156
|
+
loader. Two definitions could drift apart.
|
|
157
|
+
- A file run directly (`ruby -Itest test/capabilities/negative_dst_test.rb`)
|
|
158
|
+
runs regardless of the probe. This is how you exercise one test against a
|
|
159
|
+
database that lacks the capability.
|
|
160
|
+
- Do not use `expect_failure` for environment-dependent tests. It cannot
|
|
161
|
+
tell an absent capability from a genuine regression. It would convert
|
|
162
|
+
either into the same skip.
|
|
163
|
+
|
|
164
|
+
## Mechanism 4: Environment contracts
|
|
165
|
+
|
|
166
|
+
Each synthesized or pinned environment has a contract in
|
|
167
|
+
`test/environments/<name>_test.rb`. The CI step that creates the
|
|
168
|
+
environment invokes the contract directly:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
bundle exec ruby -Ilib -Itest test/environments/<name>_test.rb
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The suite never loads the contracts. A contract asserts its state
|
|
175
|
+
positively:
|
|
176
|
+
|
|
177
|
+
- `tzinfo_data_test.rb`: the datasource is the gem, and all three probes
|
|
178
|
+
answer true. The default environment is the one place all three
|
|
179
|
+
capabilities are guaranteed. So this contract is the tripwire for the
|
|
180
|
+
loader. A probe that rotted to false would unload its capability file
|
|
181
|
+
silently everywhere else. Here it turns red.
|
|
182
|
+
- `linkless_zoneinfo_test.rb`: `US/Eastern` raises, and the message names
|
|
183
|
+
both remedies. It also asserts the identifier count stays in the range of
|
|
184
|
+
a real links-less host. The build script's own check catches only
|
|
185
|
+
under-stripping. Over-stripping is the likelier drift. A future tzdata or
|
|
186
|
+
a different base image can add a top-level entry a real host keeps.
|
|
187
|
+
- `stale_vintage_test.rb`: `America/Nuuk` answers with the pre-2023a rules.
|
|
188
|
+
The wrong answer is asserted on purpose. A stale database's dangerous
|
|
189
|
+
failure is a wrong answer. A missing zone is easier to attribute. If the
|
|
190
|
+
pin or the rollback
|
|
191
|
+
stops taking effect, the capability-gated test simply starts loading and
|
|
192
|
+
passing. The suite goes green against a different database than the
|
|
193
|
+
environment exists for. This contract turns red instead.
|
|
194
|
+
- `system_zoneinfo_links_test.rb` and `alpine_vanguard_test.rb`: the
|
|
195
|
+
defining probe answers true. An environment that lost its defining
|
|
196
|
+
capability would silently run less. The contract is the loud half.
|
|
197
|
+
|
|
198
|
+
Without a contract, a broken setup presents as a smaller green suite. The
|
|
199
|
+
capability-gated files simply load less.
|
|
200
|
+
|
|
201
|
+
## The error messages
|
|
202
|
+
|
|
203
|
+
### Unknown identifier
|
|
204
|
+
|
|
205
|
+
`timezone_for` raises `ArgumentError` for an unknown identifier. The
|
|
206
|
+
message includes `unknown_identifier_diagnosis`. It names the database that
|
|
207
|
+
answered and how many identifiers it has. The count separates the two
|
|
208
|
+
databases legibly: approximately 600 against approximately 500.
|
|
209
|
+
|
|
210
|
+
Rules for the remedy clause:
|
|
211
|
+
|
|
212
|
+
- It is a condition the reader evaluates ("if that is what this is"). It is
|
|
213
|
+
not a claim about the identifier. Only the database is checked here.
|
|
214
|
+
Whether the name is one of the approximately 100 in IANA's `backward`
|
|
215
|
+
file is not knowable without shipping that list. A claim would tell every
|
|
216
|
+
typo on a links-less host that `tzdata-legacy` supplies it. A shipped
|
|
217
|
+
list would be worse: a name it missed would get no remedy at all.
|
|
218
|
+
- The identifier the caller passed must not appear in the remedy clause.
|
|
219
|
+
Naming it there turns the condition back into a claim.
|
|
220
|
+
- The clause appears only where the database has no links.
|
|
221
|
+
- The remedies assume the datasource is the host's default. A caller who
|
|
222
|
+
pointed `TZInfo::DataSource` at their own directory must fix that
|
|
223
|
+
directory instead. The message names the directory. That makes the case
|
|
224
|
+
recognizable.
|
|
225
|
+
|
|
226
|
+
Rules for `datasource_description`:
|
|
227
|
+
|
|
228
|
+
- The zoneinfo case is detected by capability (`respond_to?(:zoneinfo_dir)`).
|
|
229
|
+
A caller can install a custom subclass. The directory is the useful part
|
|
230
|
+
of the answer.
|
|
231
|
+
- The gem case is detected by class. Whether `tzinfo-data` is loaded says
|
|
232
|
+
nothing about whether it answered. A custom datasource in a bundle that
|
|
233
|
+
also carries the gem must not be described as tzinfo-data.
|
|
234
|
+
- An unrecognized datasource is named by its own class. Do not describe it
|
|
235
|
+
as another database.
|
|
236
|
+
- A host with no database gets its own string. This method builds failure
|
|
237
|
+
messages. It must not raise. Raising would replace the explanation with a
|
|
238
|
+
raw tzinfo error at the moment the explanation was wanted.
|
|
239
|
+
|
|
240
|
+
### No database at all
|
|
241
|
+
|
|
242
|
+
`timezone_for` raises `Duckling::TZDataUnavailable` when tzinfo raises
|
|
243
|
+
`DataSourceNotFound`. tzinfo raises it before any identifier lookup. The
|
|
244
|
+
zone name is beside the point. The message names the `reference_zone:`
|
|
245
|
+
keyword and both fixes: the `tzinfo-data` gem, or the system `tzdata`
|
|
246
|
+
package.
|
|
247
|
+
|
|
248
|
+
`TZDataUnavailable` is deliberately not an `ArgumentError`. It reports the
|
|
249
|
+
state of the deployment. A caller that validates user
|
|
250
|
+
input by rescuing `ArgumentError` must not swallow it. It is a named class
|
|
251
|
+
for the same reason as `ShapeError`: greppable, and not satisfiable by an
|
|
252
|
+
unrelated `RuntimeError`.
|
|
253
|
+
|
|
254
|
+
## Fixture zones
|
|
255
|
+
|
|
256
|
+
`test/fixtures/tz/*.zi` files are compiled by `zic` into a private zoneinfo
|
|
257
|
+
directory at test time. `TZFixtures::Datasource` swaps `TZInfo::DataSource`
|
|
258
|
+
in `setup` and restores it in `teardown`.
|
|
259
|
+
|
|
260
|
+
Why fixture zones:
|
|
261
|
+
|
|
262
|
+
- A fixture zone is identical on every host and every vintage. Real zones
|
|
263
|
+
are not. Which real zones exist depends on the datasource. What they do
|
|
264
|
+
depends on the vintage.
|
|
265
|
+
- The fixture directory exposes only its own three identifiers. Nothing
|
|
266
|
+
about the host's database can leak into a test.
|
|
267
|
+
- The swap is process-global because `timezone_for` reaches the datasource
|
|
268
|
+
through `TZInfo::Timezone.get` inside `Duckling.parse`. No injection
|
|
269
|
+
point exists. This is also why Ruby doubles cannot replace the fixture
|
|
270
|
+
zones. A double can only reach `local_time_in_zone` directly. That stops
|
|
271
|
+
short of the outside-in path through `Duckling.parse`.
|
|
272
|
+
- The restore in `teardown` is mandatory. A leaked fixture
|
|
273
|
+
datasource leaves every later test with three zones and nothing else.
|
|
274
|
+
- `TZInfo::DataSource.get` creates the default source when none is set. It
|
|
275
|
+
raises when it cannot. So setup tolerates the absence. Teardown restores
|
|
276
|
+
conditionally: `set(nil)` raises `ArgumentError`. On a failed setup that
|
|
277
|
+
would replace the real error with a worse one.
|
|
278
|
+
|
|
279
|
+
The tests reach the fixtures through `Duckling.parse`. The coverage stays
|
|
280
|
+
outside-in. One exception: the half-hour gap test calls
|
|
281
|
+
`local_time_in_zone` directly. No English expression lands reliably inside
|
|
282
|
+
a 30-minute window.
|
|
283
|
+
|
|
284
|
+
The three fixture zones:
|
|
285
|
+
|
|
286
|
+
- `Fixture/NegativeDst`: shaped like `Europe/Dublin`. Negative DST. It
|
|
287
|
+
distinguishes first-occurrence-by-position from a `dst?`-flag lookup.
|
|
288
|
+
Picking by flag gives the second occurrence, an hour off as an instant.
|
|
289
|
+
`ActiveSupport::TimeZone#local` picks by flag (`period_for_local`'s
|
|
290
|
+
`dst=true` default).
|
|
291
|
+
- `Fixture/HalfHourGap`: shaped like `Australia/Lord_Howe`. A 30-minute
|
|
292
|
+
gap. It distinguishes the transition's real width from a hardcoded
|
|
293
|
+
one-hour shift. ActiveSupport's `@time += 1.hour` retry overshoots.
|
|
294
|
+
Lord Howe is the only zone in current use with a sub-hour gap. The
|
|
295
|
+
coverage rested on one zone's continued existence.
|
|
296
|
+
- `Fixture/LateGap`: shaped like `America/Nuuk`. A gap late in the local
|
|
297
|
+
day in a negative-offset zone. The transition instant falls past the next
|
|
298
|
+
UTC midnight. `gap_delta`'s scan window must center on the skipped wall
|
|
299
|
+
clock read as UTC. A midnight-anchored window misses the transition.
|
|
300
|
+
`gap_delta` then crashes with `NoMethodError` on a nil `find`.
|
|
301
|
+
|
|
302
|
+
`zic` needs no provisioning except on Alpine:
|
|
303
|
+
|
|
304
|
+
- Debian/Ubuntu: `zic` is in `libc-bin` (Priority: required, a dependency
|
|
305
|
+
of libc6). It is not in `tzdata`. A slim image without
|
|
306
|
+
`/usr/share/zoneinfo` still has it.
|
|
307
|
+
- macOS: `/usr/sbin/zic` is a stock utility.
|
|
308
|
+
- Alpine: `zic` is in `tzdata-utils`. The `tz-containers` job installs it.
|
|
309
|
+
- `zic` lives in `sbin`. That is off a non-root `PATH`. `TZFixtures` and
|
|
310
|
+
`bin/build-stale-zoneinfo` search there explicitly.
|
|
311
|
+
- A missing `zic` is a hard error. These fixtures exist because this
|
|
312
|
+
coverage kept degrading silently on hosts nobody watched. A skip would
|
|
313
|
+
reintroduce exactly that.
|
|
314
|
+
|
|
315
|
+
## The build scripts
|
|
316
|
+
|
|
317
|
+
### `bin/build-linkless-zoneinfo <output-dir>`
|
|
318
|
+
|
|
319
|
+
Copies the host's zoneinfo directory and removes the top-level
|
|
320
|
+
backward-compatibility entries. `ZONEINFO_DIR` overrides the source
|
|
321
|
+
directory (default `/usr/share/zoneinfo`).
|
|
322
|
+
|
|
323
|
+
- The keep-list is transcribed from a real links-less host: a Debian-family
|
|
324
|
+
container with `tzdata` and no `tzdata-legacy`, 497 identifiers. No name
|
|
325
|
+
is added defensively. `Factory` and `posixrules` stay because Ubuntu
|
|
326
|
+
24.04's tzdata 2025b still has them.
|
|
327
|
+
- The script hard-fails if `US/Eastern` survives the strip. That check
|
|
328
|
+
catches under-stripping only. The environment contract's identifier-count
|
|
329
|
+
range catches over-stripping.
|
|
330
|
+
- `cp -RL` dereferences the alias symlinks. Removing an entry cannot leave
|
|
331
|
+
a dangling link. It cannot follow one back into the host's directory.
|
|
332
|
+
- The approximately 60 in-region aliases that `tzdata-legacy` also owns are
|
|
333
|
+
not removed (`America/Godthab`, `Europe/Kiev`, `Asia/Calcutta`). Inside a
|
|
334
|
+
region directory they are indistinguishable from aliases a stock host
|
|
335
|
+
keeps (`Asia/Istanbul`, `Pacific/Samoa`). So the tree exposes more
|
|
336
|
+
identifiers than a stock host. That costs nothing. The guarantee the
|
|
337
|
+
environment needs is that `US/Eastern` is genuinely gone. It is the probe
|
|
338
|
+
target and the name the CHANGELOG and the error message use.
|
|
339
|
+
- The copy keeps the host's modelling. Only the links absence gets an
|
|
340
|
+
environment contract. Modelling follows the host. The capability-gated
|
|
341
|
+
Dublin test loads or does not load.
|
|
342
|
+
|
|
343
|
+
### `bin/build-stale-zoneinfo <output-dir>`
|
|
344
|
+
|
|
345
|
+
Copies the host's zoneinfo directory and compiles
|
|
346
|
+
`test/fixtures/zoneinfo-overrides/*.zi` over it. The overrides roll named
|
|
347
|
+
zones back to earlier rules. `America/Nuuk` goes back to the pre-2023a
|
|
348
|
+
rules.
|
|
349
|
+
|
|
350
|
+
- The copy keeps the host's modelling and links state. The capability-gated
|
|
351
|
+
tests absorb both.
|
|
352
|
+
- Rolling back only the zones under assertion says plainly which staleness
|
|
353
|
+
is tested. Compiling a full old tzdata release would need a download.
|
|
354
|
+
- The override replaces the zone's entire history, including the pre-2023a
|
|
355
|
+
rules. Harmless for the contract: it only looks at 2026. Every other zone
|
|
356
|
+
in the copied directory stays as the host has it.
|
|
357
|
+
- This script needs `tzdata` installed. It copies `/usr/share/zoneinfo`. It
|
|
358
|
+
fails with a clear message if the directory is missing.
|
|
359
|
+
- The override shadows a real identifier on purpose. The
|
|
360
|
+
`test/fixtures/tz/*.zi` zones are `Fixture/`-prefixed so they cannot be
|
|
361
|
+
mistaken for real ones. Here shadowing is the point. The environment must
|
|
362
|
+
be a plausible stale host. A synthetic zone would give nothing to assert
|
|
363
|
+
against.
|
|
364
|
+
|
|
365
|
+
## `expect_failure`
|
|
366
|
+
|
|
367
|
+
`expect_failure(reason)` in `test/test_helper.rb` is for known limitations
|
|
368
|
+
that fail on every host. The upstream grammar and ranking gaps are the
|
|
369
|
+
current cases. It runs the block for real:
|
|
370
|
+
|
|
371
|
+
- A failure reports as a skip that names the reason.
|
|
372
|
+
- A pass flunks. The limitation stopped reproducing. Drop the wrapper and
|
|
373
|
+
keep the assertions.
|
|
374
|
+
- Only `Minitest::Assertion` is rescued. It inherits from `Exception`, not
|
|
375
|
+
from `StandardError`. A wider rescue would launder any crash before the
|
|
376
|
+
assertions into "known limitation". A genuine regression must surface as
|
|
377
|
+
a crash.
|
|
378
|
+
- A `skip` inside the block is re-raised first. `Minitest::Skip` is a
|
|
379
|
+
subclass of `Minitest::Assertion`. Otherwise the real explanation would
|
|
380
|
+
be replaced by the reason.
|
|
381
|
+
|
|
382
|
+
Do not use it for anything environment-dependent. Use
|
|
383
|
+
`test/capabilities/` instead.
|
|
384
|
+
|
|
385
|
+
## Running an environment locally
|
|
386
|
+
|
|
387
|
+
Always pass `BUNDLE_LOCKFILE` to both the `bundle install` and the run.
|
|
388
|
+
The suite adapts itself to whatever database it gets. There is no
|
|
389
|
+
environment name to set. Run the matching contract afterward to prove you
|
|
390
|
+
got the state you meant to.
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
# no tzinfo-data: the host's zoneinfo files
|
|
394
|
+
export DUCKLING_TZINFO_DATA=none BUNDLE_LOCKFILE=Gemfile.system-zoneinfo.lock
|
|
395
|
+
bundle install && bundle exec rake test
|
|
396
|
+
|
|
397
|
+
# the same, with the backward-compat links stripped (US/Eastern stops resolving)
|
|
398
|
+
bin/build-linkless-zoneinfo /tmp/linkless-zoneinfo
|
|
399
|
+
DUCKLING_ZONEINFO_DIR=/tmp/linkless-zoneinfo bundle exec rake test
|
|
400
|
+
DUCKLING_ZONEINFO_DIR=/tmp/linkless-zoneinfo \
|
|
401
|
+
bundle exec ruby -Ilib -Itest test/environments/linkless_zoneinfo_test.rb
|
|
402
|
+
|
|
403
|
+
# a pinned stale vintage
|
|
404
|
+
export DUCKLING_TZINFO_DATA=1.2022.7 BUNDLE_LOCKFILE=Gemfile.tzinfo-data-1.2022.7.lock
|
|
405
|
+
bundle install && bundle exec rake test
|
|
406
|
+
bundle exec ruby -Ilib -Itest test/environments/stale_vintage_test.rb
|
|
407
|
+
|
|
408
|
+
# both axes at once
|
|
409
|
+
bin/build-stale-zoneinfo /tmp/stale-zoneinfo
|
|
410
|
+
export DUCKLING_TZINFO_DATA=none BUNDLE_LOCKFILE=Gemfile.stale-system-zoneinfo.lock
|
|
411
|
+
bundle install && DUCKLING_ZONEINFO_DIR=/tmp/stale-zoneinfo bundle exec rake test
|
|
412
|
+
DUCKLING_ZONEINFO_DIR=/tmp/stale-zoneinfo \
|
|
413
|
+
bundle exec ruby -Ilib -Itest test/environments/stale_vintage_test.rb
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
`DUCKLING_ZONEINFO_DIR` alone (pointing at `/usr/share/zoneinfo`, with
|
|
417
|
+
`tzinfo-data` still bundled) reaches the same datasource as the system leg
|
|
418
|
+
without re-resolving anything. It is the quick way to reproduce a
|
|
419
|
+
system-zoneinfo failure. It is not the same configuration. The gem is still
|
|
420
|
+
installed. So it does not exercise tzinfo's own fallback.
|
|
421
|
+
|
|
422
|
+
## CI notes
|
|
423
|
+
|
|
424
|
+
- `bundler-cache` is off in `timezones` and `tz-containers`. These
|
|
425
|
+
environments resolve a different bundle than the committed lockfile. The
|
|
426
|
+
cache is keyed on that lockfile.
|
|
427
|
+
- The system-zoneinfo step in `baseline` runs
|
|
428
|
+
`bundle config unset --local deployment` (and `frozen`).
|
|
429
|
+
`bundler-cache: true` writes `deployment: true` into `.bundle/config`.
|
|
430
|
+
Deployment requires a committed lockfile. The environment lockfiles are
|
|
431
|
+
gitignored by design. It must be unset in the local config.
|
|
432
|
+
`BUNDLE_DEPLOYMENT` cannot override it. Bundler resolves local config first and environment
|
|
433
|
+
variables second (`Bundler::Settings#configs`). An env var cannot
|
|
434
|
+
override anything `bundle config --local` has written.
|
|
435
|
+
- The Alpine image is pinned by digest. The floating `ruby:3.4-alpine` tag
|
|
436
|
+
silently rebases across Alpine releases. Alpine drops older versioned
|
|
437
|
+
clang packages as it rolls. A rebase also moves the host's tz data under
|
|
438
|
+
a leg that asserts against it. Bump by resolving the tag's current digest
|
|
439
|
+
(`docker buildx imagetools inspect ruby:3.4-alpine`). Then re-verify the
|
|
440
|
+
clang package names and the contract against the new release.
|
|
441
|
+
- Alpine splits clang's resource headers (`stdckdint.h`, which ruby-3.4's
|
|
442
|
+
headers include) away from the library's default search path.
|
|
443
|
+
`BINDGEN_EXTRA_CLANG_ARGS=-I<resource-dir>` points bindgen at them. Any
|
|
444
|
+
musl consumer building the source gem needs the same. No precompiled gem
|
|
445
|
+
targets musl (`cross_targets.rb`). So the source build is the path musl
|
|
446
|
+
consumers actually take.
|
|
447
|
+
- The banner line at suite start (`tz datasource: ...; negative_dst=true
|
|
448
|
+
...`) records which database answered and which probes passed. Two images
|
|
449
|
+
with the same name can disagree about the links. The banner is what
|
|
450
|
+
reconciles a missing capability test with a CI log.
|
|
451
|
+
- The container jobs run the image's own Ruby. `ruby/setup-ruby` does not
|
|
452
|
+
apply (it has no musl support). The toolchain comes from the image's
|
|
453
|
+
package manager, git included. That is why the install step precedes
|
|
454
|
+
checkout.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tzinfo"
|
|
4
|
+
|
|
5
|
+
module Duckling
|
|
6
|
+
# Which tz database `reference_zone:` resolves against, for the
|
|
7
|
+
# unknown-identifier error message. Behavioral because neither datasource
|
|
8
|
+
# exposes a version. Internal. See docs/tz-database-axis.md.
|
|
9
|
+
module TZInfoCapabilities
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# DataSourceNotFound does not inherit from InvalidTimezoneIdentifier;
|
|
13
|
+
# name it explicitly. A host with no database answers false.
|
|
14
|
+
def backward_compat_links?
|
|
15
|
+
TZInfo::Timezone.get("US/Eastern")
|
|
16
|
+
true
|
|
17
|
+
rescue TZInfo::InvalidTimezoneIdentifier, TZInfo::DataSourceNotFound
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def identifier_count
|
|
22
|
+
TZInfo::Timezone.all_identifiers.size
|
|
23
|
+
rescue TZInfo::DataSourceNotFound
|
|
24
|
+
0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Zoneinfo is detected by capability (a caller may install a subclass),
|
|
28
|
+
# the gem by class (loaded is not answered). Must not raise: it builds
|
|
29
|
+
# failure messages.
|
|
30
|
+
def datasource_description
|
|
31
|
+
source = begin
|
|
32
|
+
TZInfo::DataSource.get
|
|
33
|
+
rescue TZInfo::DataSourceNotFound
|
|
34
|
+
return "no tz datasource (no zoneinfo files, no tzinfo-data gem)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return "system zoneinfo at #{source.zoneinfo_dir}" if source.respond_to?(:zoneinfo_dir)
|
|
38
|
+
|
|
39
|
+
if defined?(TZInfo::DataSources::RubyDataSource) && source.is_a?(TZInfo::DataSources::RubyDataSource)
|
|
40
|
+
version = " (tzdata #{TZInfo::Data::Version::TZDATA})" if defined?(TZInfo::Data::Version::TZDATA)
|
|
41
|
+
return "the tzinfo-data gem#{version}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
"the #{source.class} tz datasource"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The remedy is phrased as a condition: only the database is checked.
|
|
48
|
+
# See docs/tz-database-axis.md.
|
|
49
|
+
def unknown_identifier_diagnosis
|
|
50
|
+
diagnosis = "resolved against #{datasource_description}, " \
|
|
51
|
+
"which provides #{identifier_count} identifiers"
|
|
52
|
+
return diagnosis if backward_compat_links?
|
|
53
|
+
|
|
54
|
+
"#{diagnosis}; this database has no backward-compat names (US/Eastern and ~100 others), " \
|
|
55
|
+
"so if that is what this is, it needs either the tzinfo-data gem or the " \
|
|
56
|
+
"tzdata-legacy system package"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|