redis-objects-periodical 0.7.0 → 0.8.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/dependabot-batch-maintenance/SKILL.md +141 -0
  3. data/.github/dependabot.yml +32 -3
  4. data/.github/scripts/dependabot-auto-merge.cjs +182 -0
  5. data/.github/scripts/dependabot-auto-merge.test.cjs +278 -0
  6. data/.github/scripts/dependabot-policy.cjs +132 -0
  7. data/.github/scripts/dependabot-policy.test.cjs +235 -0
  8. data/.github/scripts/timezone-inventory.cjs +48 -0
  9. data/.github/scripts/timezone-inventory.test.cjs +86 -0
  10. data/.github/workflows/checks.yml +124 -0
  11. data/.github/workflows/ci.yml +191 -0
  12. data/.github/workflows/dependabot-auto-merge.yml +61 -0
  13. data/.github/workflows/release.yml +59 -0
  14. data/.github/workflows/rubocop_challenge.yml +71 -0
  15. data/.rspec +1 -0
  16. data/.rubocop.yml +1 -1
  17. data/.rubocop_todo.yml +16 -16
  18. data/AGENTS.md +133 -0
  19. data/CHANGELOG.md +47 -0
  20. data/CONTRIBUTING.md +203 -0
  21. data/Dockerfile +3 -2
  22. data/Gemfile +6 -6
  23. data/Gemfile.lock +70 -65
  24. data/README.md +58 -221
  25. data/docker-compose.yml +1 -3
  26. data/docs/maintaining.md +237 -0
  27. data/docs/usage.md +216 -0
  28. data/docs/verification/2026-10-27-timezone.md +25 -0
  29. data/gemfiles/active_support/Gemfile +5 -0
  30. data/gemfiles/active_support/Gemfile.lock +137 -0
  31. data/lib/redis/objects/periodical/version.rb +1 -1
  32. data/lib/redis/objects/periodical_counters.rb +1 -1
  33. data/lib/redis/objects/periodical_hashes.rb +1 -1
  34. data/lib/redis/objects/periodical_sets.rb +1 -1
  35. data/lib/redis/objects/periodical_values.rb +1 -1
  36. data/lib/redis/periodical_counter.rb +1 -1
  37. data/lib/redis/periodical_hash_key.rb +1 -1
  38. data/lib/redis/periodical_set.rb +1 -1
  39. data/lib/redis/periodical_value.rb +1 -1
  40. data/lib/redis/recurring_at_intervals/weekly.rb +3 -1
  41. data/lib/redis/recurring_at_intervals.rb +3 -3
  42. data/redis-objects-periodical.gemspec +1 -1
  43. metadata +26 -8
  44. data/.circleci/config.yml +0 -212
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ const developmentGems = new Set([
4
+ 'pry-byebug', 'rake', 'rspec', 'rspec_junit_formatter', 'rubocop',
5
+ 'rubocop-performance', 'rubocop-rspec', 'simplecov', 'timecop', 'yard',
6
+ ]);
7
+ const officialActions = new Set([
8
+ 'actions/checkout', 'actions/upload-artifact', 'actions/download-artifact',
9
+ 'actions/github-script',
10
+ ]);
11
+ const requiredChecks = [
12
+ 'checks / build_on_ruby_3.3', 'checks / build_on_ruby_3.4', 'checks / build_on_ruby_4.0',
13
+ 'checks / rubocop', 'yardoc', 'upload-coverage', 'active-support-timezone', 'dependabot-policy',
14
+ ];
15
+
16
+ function changedLines(file) {
17
+ if (file.status !== 'modified' || !file.patch) return null;
18
+ const lines = file.patch.split('\n');
19
+ const removed = lines.filter(line => line.startsWith('-')).map(line => line.slice(1));
20
+ const added = lines.filter(line => line.startsWith('+')).map(line => line.slice(1));
21
+ // Missing or truncated patches must never turn an incomplete inspection into approval.
22
+ if (removed.length !== file.deletions || added.length !== file.additions) return null;
23
+ return { removed, added };
24
+ }
25
+
26
+ function protectedLockEntries(lock) {
27
+ // Runtime dependencies from the gemspec and their locked dependency closure.
28
+ const entries = [...lock.matchAll(/^ (?:redis-objects-periodical|redis-objects|redis|redis-client|connection_pool) \([^\n]+\)\n(?: {6}[^\n]+\n)*/gm)];
29
+ return entries.map(entry => entry[0]).sort();
30
+ }
31
+
32
+ function assessUpdate({ metadata, files, baseLock = '', headLock = '' }) {
33
+ const smallUpdate = ['version-update:semver-patch', 'version-update:semver-minor'];
34
+ if (!smallUpdate.includes(metadata.updateType)) return false;
35
+ const names = metadata.dependencyNames.split(',').map(name => name.trim()).filter(Boolean);
36
+ if (names.length === 0 || files.length === 0) return false;
37
+
38
+ if (metadata.ecosystem === 'bundler') {
39
+ if (!names.every(name => developmentGems.has(name))) return false;
40
+ // Manifest/constraint edits, optional integration dependencies, and Bundler stay manual.
41
+ if (files.length !== 1 || files[0].filename !== 'Gemfile.lock') return false;
42
+ const lines = changedLines(files[0]);
43
+ if (!lines || !baseLock || !headLock) return false;
44
+ const before = protectedLockEntries(baseLock);
45
+ const after = protectedLockEntries(headLock);
46
+ if (before.length !== 5 || JSON.stringify(before) !== JSON.stringify(after)) return false;
47
+ // Only gem spec/version and dependency requirement lines may change, never sources,
48
+ // platforms, direct constraints, PATH entries, or BUNDLED WITH.
49
+ return [...lines.removed, ...lines.added].every(line =>
50
+ /^ [\w-]+ \([0-9][^)]*\)$/.test(line) || /^ [\w-]+(?: \([^)]*\))?$/.test(line));
51
+ }
52
+
53
+ if (metadata.ecosystem !== 'github_actions' || !names.every(name => officialActions.has(name))) {
54
+ return false;
55
+ }
56
+ return files.every(file => {
57
+ if (!/^\.github\/workflows\/[^/]+\.ya?ml$/.test(file.filename)) return false;
58
+ const lines = changedLines(file);
59
+ if (!lines || lines.removed.length === 0 || lines.removed.length !== lines.added.length) return false;
60
+ const signature = line => {
61
+ const match = line.match(/^(\s*(?:-\s*)?uses:\s*)(actions\/[\w-]+)@[a-f0-9]{40}(?:\s+#.*)?$/);
62
+ return match && names.includes(match[2]) ? match[1] + match[2] : null;
63
+ };
64
+ const removed = lines.removed.map(signature);
65
+ const added = lines.added.map(signature);
66
+ return !removed.includes(null) && !added.includes(null) &&
67
+ JSON.stringify(removed.sort()) === JSON.stringify(added.sort());
68
+ });
69
+ }
70
+
71
+ function protectionReady(repository, rules = []) {
72
+ if (!repository.autoMergeAllowed || rules.some(rule => rule.type === 'merge_queue')) return false;
73
+ const protection = repository.ref && repository.ref.branchProtectionRule;
74
+ const contexts = new Set();
75
+ let strict = false;
76
+ if (protection && protection.requiresStatusChecks) {
77
+ strict = protection.requiresStrictStatusChecks;
78
+ protection.requiredStatusCheckContexts.forEach(name => contexts.add(name));
79
+ }
80
+ for (const rule of rules.filter(item => item.type === 'required_status_checks')) {
81
+ strict = strict || rule.parameters.strict_required_status_checks_policy;
82
+ rule.parameters.required_status_checks.forEach(check => contexts.add(check.context));
83
+ }
84
+ return strict && requiredChecks.every(name => contexts.has(name));
85
+ }
86
+
87
+ async function evaluate({ github, context, core, metadata }) {
88
+ const { owner, repo } = context.repo;
89
+ const number = context.payload.pull_request.number;
90
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
91
+ if (pr.user.login !== 'dependabot[bot]' || pr.state !== 'open' || pr.draft ||
92
+ pr.base.ref !== 'master' || pr.base.repo.full_name !== `${owner}/${repo}` ||
93
+ !pr.head.repo || pr.head.repo.full_name !== `${owner}/${repo}` ||
94
+ pr.head.sha !== context.payload.pull_request.head.sha) return false;
95
+
96
+ const files = await github.paginate(github.rest.pulls.listFiles, {
97
+ owner, repo, pull_number: number, per_page: 100,
98
+ });
99
+ // GitHub's files endpoint is capped; refuse incomplete inspection.
100
+ if (files.length !== pr.changed_files) return false;
101
+ const input = { metadata, files };
102
+ if (metadata.ecosystem === 'bundler') {
103
+ const lockAt = async ref => {
104
+ const { data } = await github.rest.repos.getContent({ owner, repo, path: 'Gemfile.lock', ref });
105
+ if (data.encoding !== 'base64' || !data.content) throw new Error('Missing lockfile contents');
106
+ return Buffer.from(data.content, 'base64').toString('utf8');
107
+ };
108
+ [input.baseLock, input.headLock] = await Promise.all([lockAt(pr.base.sha), lockAt(pr.head.sha)]);
109
+ }
110
+ if (!assessUpdate(input)) return false;
111
+
112
+ const result = await github.graphql(`query($owner: String!, $repo: String!) {
113
+ repository(owner: $owner, name: $repo) {
114
+ autoMergeAllowed
115
+ ref(qualifiedName: "refs/heads/master") {
116
+ branchProtectionRule {
117
+ requiresStatusChecks requiresStrictStatusChecks requiredStatusCheckContexts
118
+ }
119
+ }
120
+ }
121
+ }`, { owner, repo });
122
+ const rules = await github.paginate('GET /repos/{owner}/{repo}/rules/branches/{branch}', {
123
+ owner, repo, branch: 'master', per_page: 100,
124
+ });
125
+ if (!protectionReady(result.repository, rules)) {
126
+ core.notice('Auto-merge requires the documented strict branch protection and all required CI checks.');
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ module.exports = { assessUpdate, protectionReady, evaluate, requiredChecks };
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const { readFileSync } = require('node:fs');
5
+ const { join } = require('node:path');
6
+ const { assessUpdate, protectionReady, evaluate, requiredChecks } = require('./dependabot-policy.cjs');
7
+
8
+ const metadata = {
9
+ ecosystem: 'bundler', updateType: 'version-update:semver-patch', dependencyNames: 'rspec',
10
+ };
11
+ const baseLock = `PATH
12
+ remote: .
13
+ specs:
14
+ redis-objects-periodical (0.7.0)
15
+ redis-objects (~> 1.0)
16
+
17
+ GEM
18
+ remote: https://rubygems.org/
19
+ specs:
20
+ connection_pool (2.3.0)
21
+ redis (5.0.6)
22
+ redis-client (>= 0.9.0)
23
+ redis-client (0.12.1)
24
+ connection_pool
25
+ redis-objects (1.7.0)
26
+ redis
27
+ rspec (3.13.1)
28
+ rspec-core (~> 3.13.0)
29
+
30
+ BUNDLED WITH
31
+ 4.0.23
32
+ `;
33
+ const headLock = baseLock.replace('rspec (3.13.1)', 'rspec (3.13.2)');
34
+ const file = (filename, removed, added) => ({
35
+ filename, status: 'modified', deletions: removed.length, additions: added.length,
36
+ patch: ['@@ -1,1 +1,1 @@', ...removed.map(line => '-' + line), ...added.map(line => '+' + line)].join('\n'),
37
+ });
38
+ const lockFile = file('Gemfile.lock', [' rspec (3.13.1)'], [' rspec (3.13.2)']);
39
+ const lockInput = { metadata, files: [lockFile], baseLock, headLock };
40
+ // Representative outputs from the pinned dependabot/fetch-metadata action:
41
+ // src/dependabot/update_metadata.ts derives this ecosystem from dependabot/github_actions/...
42
+ const actionOutputs = {
43
+ 'package-ecosystem': 'github_actions',
44
+ 'update-type': 'version-update:semver-patch',
45
+ 'dependency-names': 'actions/checkout',
46
+ };
47
+ const actionMetadata = {
48
+ ecosystem: actionOutputs['package-ecosystem'],
49
+ updateType: actionOutputs['update-type'],
50
+ dependencyNames: actionOutputs['dependency-names'],
51
+ };
52
+ const actionFile = file('.github/workflows/ci.yml',
53
+ [' - uses: actions/checkout@' + 'a'.repeat(40) + ' # v6.0.0'],
54
+ [' - uses: actions/checkout@' + 'b'.repeat(40) + ' # v6.0.1']);
55
+ const actionInput = { metadata: actionMetadata, files: [actionFile] };
56
+
57
+ test('permits an allowlisted development lockfile patch or minor', () => {
58
+ assert.equal(assessUpdate(lockInput), true);
59
+ assert.equal(assessUpdate({ ...lockInput, metadata: { ...metadata, updateType: 'version-update:semver-minor' } }), true);
60
+ });
61
+ for (const name of ['redis-objects', 'redis', 'bundler', 'activesupport', 'unknown', 'rspec, redis-objects', '']) {
62
+ test(`rejects unapproved dependency list ${JSON.stringify(name)}`, () => {
63
+ assert.equal(assessUpdate({ ...lockInput, metadata: { ...metadata, dependencyNames: name } }), false);
64
+ });
65
+ }
66
+ for (const updateType of ['version-update:semver-major', '', 'unknown']) {
67
+ test(`rejects update type ${JSON.stringify(updateType)}`, () => {
68
+ assert.equal(assessUpdate({ ...lockInput, metadata: { ...metadata, updateType } }), false);
69
+ });
70
+ }
71
+ test('requires every dependency in a grouped update to be allowlisted', () => {
72
+ assert.equal(assessUpdate({ ...lockInput, metadata: { ...metadata, dependencyNames: 'rspec, yard' } }), true);
73
+ });
74
+ for (const filename of ['Gemfile', 'redis-objects-periodical.gemspec', 'gemfiles/active_support/Gemfile', 'gemfiles/active_support/Gemfile.lock', 'lib/redis-objects-periodical.rb']) {
75
+ test(`rejects manifest, integration, or implementation edits: ${filename}`, () => {
76
+ assert.equal(assessUpdate({ ...lockInput, files: [{ ...lockFile, filename }] }), false);
77
+ });
78
+ }
79
+ test('rejects any additional file, even alongside a valid lockfile change', () => {
80
+ assert.equal(assessUpdate({ ...lockInput, files: [lockFile, { ...lockFile, filename: 'Gemfile' }] }), false);
81
+ });
82
+ test('keeps a shared dependency update in both lockfiles under manual review', () => {
83
+ const integrationLock = { ...lockFile, filename: 'gemfiles/active_support/Gemfile.lock' };
84
+ assert.equal(assessUpdate({ ...lockInput, files: [lockFile, integrationLock] }), false);
85
+ });
86
+ for (const name of ['connection_pool', 'redis', 'redis-client', 'redis-objects', 'redis-objects-periodical']) {
87
+ test(`rejects changes to protected runtime entry ${name}`, () => {
88
+ const altered = headLock.replace(new RegExp(`( ${name} \\()[^)]*`), (_, prefix) => prefix + '99.0.0');
89
+ assert.equal(assessUpdate({ ...lockInput, headLock: altered }), false);
90
+ });
91
+ }
92
+ for (const [removed, added] of [
93
+ [' remote: https://rubygems.org/', ' remote: https://example.com/'],
94
+ [' 4.0.23', ' 4.1.0'],
95
+ [' rspec (~> 3.13)', ' rspec (~> 4.0)'],
96
+ [' x86_64-linux', ' other-platform'],
97
+ ]) {
98
+ test(`rejects lockfile control changes: ${removed.trim()}`, () => {
99
+ assert.equal(assessUpdate({ ...lockInput, files: [file('Gemfile.lock', [removed], [added])] }), false);
100
+ });
101
+ }
102
+ test('rejects absent, truncated, added or deleted files', () => {
103
+ for (const change of [{ patch: undefined }, { additions: 100 }, { status: 'added' }, { status: 'removed' }]) {
104
+ assert.equal(assessUpdate({ ...lockInput, files: [{ ...lockFile, ...change }] }), false);
105
+ }
106
+ assert.equal(assessUpdate({ ...lockInput, files: [] }), false);
107
+ assert.equal(assessUpdate({ ...lockInput, baseLock: '' }), false);
108
+ });
109
+ test('permits only pinned official action version replacements', () => {
110
+ assert.equal(assessUpdate(actionInput), true);
111
+ });
112
+ test('rejects tags, third-party actions, privileges, triggers, steps and missing patches', () => {
113
+ for (const added of [
114
+ ' - uses: actions/checkout@v6.0.1',
115
+ ' - uses: other/checkout@' + 'b'.repeat(40),
116
+ 'permissions: write-all', ' pull_request_target:', ' - run: echo unsafe',
117
+ ]) {
118
+ const unsafe = file(actionFile.filename, [' - uses: actions/checkout@' + 'a'.repeat(40)], [added]);
119
+ assert.equal(assessUpdate({ ...actionInput, files: [unsafe] }), false);
120
+ }
121
+ assert.equal(assessUpdate({ ...actionInput, files: [{ ...actionFile, patch: undefined }] }), false);
122
+ assert.equal(assessUpdate({ ...actionInput, metadata: { ...actionMetadata, dependencyNames: 'actions/checkout, ruby/setup-ruby' } }), false);
123
+ });
124
+ test('rejects an official-action PR that also alters permissions', () => {
125
+ const unsafe = file(actionFile.filename,
126
+ [' - uses: actions/checkout@' + 'a'.repeat(40), ' contents: read'],
127
+ [' - uses: actions/checkout@' + 'b'.repeat(40), ' contents: write']);
128
+ assert.equal(assessUpdate({ ...actionInput, files: [unsafe] }), false);
129
+ });
130
+ for (const ecosystem of ['github-actions', 'github_action', '', 'unknown']) {
131
+ test(`rejects an unrecognized Actions ecosystem ${JSON.stringify(ecosystem)}`, () => {
132
+ assert.equal(assessUpdate({ ...actionInput, metadata: { ...actionMetadata, ecosystem } }), false);
133
+ });
134
+ }
135
+ test('rejects unknown ecosystems and non-workflow action changes', () => {
136
+ assert.equal(assessUpdate({ ...actionInput, metadata: { ...actionMetadata, ecosystem: 'docker' } }), false);
137
+ assert.equal(assessUpdate({ ...actionInput, files: [{ ...actionFile, filename: '.github/scripts/policy.cjs' }] }), false);
138
+ });
139
+
140
+ const repository = {
141
+ autoMergeAllowed: true,
142
+ ref: { branchProtectionRule: {
143
+ requiresStatusChecks: true, requiresStrictStatusChecks: true,
144
+ requiredStatusCheckContexts: requiredChecks,
145
+ } },
146
+ };
147
+ test('requires auto-merge and every documented strict required check', () => {
148
+ assert.equal(protectionReady(repository), true);
149
+ assert.equal(Boolean(protectionReady({ ...repository, autoMergeAllowed: false })), false);
150
+ assert.equal(Boolean(protectionReady({ ...repository, ref: null })), false);
151
+ for (const name of requiredChecks) {
152
+ const protection = { ...repository.ref.branchProtectionRule,
153
+ requiredStatusCheckContexts: requiredChecks.filter(check => check !== name) };
154
+ assert.equal(protectionReady({ ...repository, ref: { branchProtectionRule: protection } }), false);
155
+ }
156
+ assert.equal(protectionReady({ ...repository, ref: { branchProtectionRule: {
157
+ ...repository.ref.branchProtectionRule, requiresStrictStatusChecks: false,
158
+ } } }), false);
159
+ });
160
+
161
+ function harness() {
162
+ const pr = { number: 1, user: { login: 'dependabot[bot]' }, state: 'open', draft: false,
163
+ base: { ref: 'master', sha: 'base', repo: { full_name: 'ryz310/redis-objects-periodical' } },
164
+ head: { sha: 'head', repo: { full_name: 'ryz310/redis-objects-periodical' } }, changed_files: 1 };
165
+ const context = { repo: { owner: 'ryz310', repo: 'redis-objects-periodical' },
166
+ payload: { pull_request: { number: 1, head: { sha: 'head' } } } };
167
+ const github = {
168
+ rest: { pulls: { get: async () => ({ data: pr }), listFiles: 'files' }, repos: {
169
+ getContent: async ({ ref }) => ({ data: { encoding: 'base64',
170
+ content: Buffer.from(ref === 'base' ? baseLock : headLock).toString('base64') } }),
171
+ } },
172
+ paginate: async endpoint => endpoint === 'files' ? [lockFile] : [],
173
+ graphql: async () => ({ repository }),
174
+ };
175
+ return { pr, github, context, core: { notice() {} }, metadata };
176
+ }
177
+ test('reads current PR and both immutable lockfile revisions without API writes', async () => {
178
+ assert.equal(await evaluate(harness()), true);
179
+ });
180
+ for (const scenario of ['human', 'fork', 'stale', 'release', 'closed', 'draft', 'missing-files']) {
181
+ test(`rejects current PR state: ${scenario}`, async () => {
182
+ const input = harness();
183
+ if (scenario === 'human') input.pr.user.login = 'someone';
184
+ if (scenario === 'fork') input.pr.head.repo.full_name = 'someone/fork';
185
+ if (scenario === 'stale') input.pr.head.sha = 'new-head';
186
+ if (scenario === 'release') input.pr.base.ref = 'release';
187
+ if (scenario === 'closed') input.pr.state = 'closed';
188
+ if (scenario === 'draft') input.pr.draft = true;
189
+ if (scenario === 'missing-files') input.pr.changed_files = 2;
190
+ assert.equal(await evaluate(input), false);
191
+ });
192
+ }
193
+ test('API or branch protection read failure cannot produce an approval', async () => {
194
+ const input = harness();
195
+ input.github.graphql = async () => { throw new Error('Forbidden'); };
196
+ await assert.rejects(evaluate(input), /Forbidden/);
197
+ });
198
+
199
+ const rules = [{ type: 'required_status_checks', parameters: {
200
+ strict_required_status_checks_policy: true,
201
+ required_status_checks: requiredChecks.map(context => ({ context })),
202
+ } }];
203
+ const rulesRepository = { autoMergeAllowed: true, ref: { branchProtectionRule: null } };
204
+ test('accepts effective rulesets without requiring classic branch protection', () => {
205
+ assert.equal(protectionReady(rulesRepository, rules), true);
206
+ });
207
+ test('rulesets must require every check and strict updates', () => {
208
+ assert.equal(protectionReady(rulesRepository, [{ type: 'required_status_checks', parameters: {
209
+ ...rules[0].parameters, strict_required_status_checks_policy: false,
210
+ } }]), false);
211
+ for (const name of requiredChecks) {
212
+ assert.equal(protectionReady(rulesRepository, [{ type: 'required_status_checks', parameters: {
213
+ ...rules[0].parameters,
214
+ required_status_checks: rules[0].parameters.required_status_checks.filter(check => check.context !== name),
215
+ } }]), false);
216
+ }
217
+ });
218
+ test('rejects merge queues even with all checks configured', () => {
219
+ assert.equal(protectionReady(repository, [...rules, { type: 'merge_queue' }]), false);
220
+ });
221
+ test('checks effective branch rules before approving an update', async () => {
222
+ const input = harness();
223
+ input.github.graphql = async () => ({ repository: rulesRepository });
224
+ input.github.paginate = async endpoint => endpoint === 'files' ? [lockFile] : rules;
225
+ assert.equal(await evaluate(input), true);
226
+ });
227
+
228
+ test('required Ruby checks match the reusable CI matrix', () => {
229
+ const workflow = readFileSync(join(__dirname, '../workflows/checks.yml'), 'utf8');
230
+ const matrix = workflow.match(/ruby:\s*\[([^\]]+)\]/);
231
+ assert.ok(matrix, 'Expected an explicit Ruby matrix in checks.yml');
232
+ const versions = matrix[1].split(',').map(value => value.trim().replace(/['"]/g, ''));
233
+ assert.deepEqual(requiredChecks.filter(name => name.startsWith('checks / build_on_ruby_')),
234
+ versions.map(version => `checks / build_on_ruby_${version}`));
235
+ });
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const { readFileSync } = require('node:fs');
4
+
5
+ // Independent execution contract for the integration fixtures. Update this definition
6
+ // when deliberately adding/removing fixture dimensions; do not derive it from the report.
7
+ const inventory = {
8
+ boundaries: ['year and month boundary', 'week boundary'],
9
+ zones: ['"Asia/Tokyo"', 'nil'],
10
+ periods: ['annual', 'monthly', 'weekly', 'daily', 'hourly', 'minutely'],
11
+ types: ['counter', 'value', 'set', 'hash_key'],
12
+ };
13
+ const expectedDescriptions = inventory.boundaries.flatMap(boundary =>
14
+ inventory.zones.flatMap(zone =>
15
+ inventory.periods.flatMap(period =>
16
+ inventory.types.map(type =>
17
+ `Redis::RecurringAtIntervals when crossing the ${boundary} with Time.zone = ${zone} persists the ${period} ${type} in the selected timezone`))));
18
+
19
+ function validateInventory(report) {
20
+ if (!Array.isArray(report.examples)) throw new Error('Missing RSpec examples array');
21
+ const expected = new Set(expectedDescriptions);
22
+ const seen = new Set();
23
+ const problems = [];
24
+ for (const example of report.examples) {
25
+ const name = example.full_description;
26
+ if (!expected.has(name)) problems.push(`Unexpected example: ${name}`);
27
+ if (seen.has(name)) problems.push(`Duplicate example: ${name}`);
28
+ if (example.status !== 'passed') problems.push(`Example ${example.status}: ${name}`);
29
+ seen.add(name);
30
+ }
31
+ for (const name of expected) {
32
+ if (!seen.has(name)) problems.push(`Missing example: ${name}`);
33
+ }
34
+ if (problems.length) throw new Error(problems.join('\n'));
35
+ return seen.size;
36
+ }
37
+
38
+ if (require.main === module) {
39
+ try {
40
+ const count = validateInventory(JSON.parse(readFileSync(process.argv[2], 'utf8')));
41
+ console.log(`Verified ${count} passed timezone integration examples`);
42
+ } catch (error) {
43
+ console.error(`Timezone inventory validation failed: ${error.message}`);
44
+ process.exitCode = 1;
45
+ }
46
+ }
47
+
48
+ module.exports = { expectedDescriptions, validateInventory };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { spawnSync } = require('node:child_process');
6
+ const { mkdtempSync, rmSync, writeFileSync } = require('node:fs');
7
+ const { tmpdir } = require('node:os');
8
+ const { join } = require('node:path');
9
+ const { expectedDescriptions, validateInventory } = require('./timezone-inventory.cjs');
10
+
11
+ const complete = () => ({
12
+ examples: expectedDescriptions.map(full_description => ({ full_description, status: 'passed' })),
13
+ });
14
+
15
+ test('accepts the complete passed inventory independently of report order', () => {
16
+ const report = complete();
17
+ report.examples.reverse();
18
+ assert.equal(validateInventory(report), expectedDescriptions.length);
19
+ });
20
+
21
+ for (const [dimension, match] of [
22
+ ['Tokyo zone', 'with Time.zone = "Asia/Tokyo"'],
23
+ ['unset zone', 'with Time.zone = nil'],
24
+ ['year/month boundary', 'when crossing the year and month boundary'],
25
+ ['week boundary', 'when crossing the week boundary'],
26
+ ['period', 'persists the weekly '],
27
+ ['type', ' hash_key in the selected timezone'],
28
+ ]) {
29
+ test(`rejects a missing ${dimension}`, () => {
30
+ const report = complete();
31
+ report.examples = report.examples.filter(example => !example.full_description.includes(match));
32
+ assert.throws(() => validateInventory(report), /Missing example/);
33
+ });
34
+ }
35
+
36
+ test('rejects an empty selection even when RSpec reports zero failures', () => {
37
+ assert.throws(() => validateInventory({ examples: [], summary: { failure_count: 0 } }), /Missing example/);
38
+ });
39
+
40
+ test('rejects a single missing combination even if a duplicate preserves the total count', () => {
41
+ const report = complete();
42
+ report.examples[0] = report.examples[1];
43
+ assert.throws(() => validateInventory(report), /Duplicate example[\s\S]*Missing example/);
44
+ });
45
+
46
+ for (const status of ['pending', 'failed', 'skipped', undefined]) {
47
+ test(`rejects an intended example with status ${status}`, () => {
48
+ const report = complete();
49
+ report.examples[0].status = status;
50
+ assert.throws(() => validateInventory(report), /Example/);
51
+ });
52
+ }
53
+
54
+ test('rejects unexpected examples rather than allowing substitutions', () => {
55
+ const report = complete();
56
+ report.examples[0].full_description = 'unrelated passed example';
57
+ assert.throws(() => validateInventory(report), /Unexpected example[\s\S]*Missing example/);
58
+ });
59
+
60
+ test('rejects a malformed report', () => {
61
+ assert.throws(() => validateInventory({}), /Missing RSpec examples array/);
62
+ });
63
+
64
+ test('CLI fails closed for missing, invalid, empty and pending reports, and accepts complete output', () => {
65
+ const directory = mkdtempSync(join(tmpdir(), 'timezone-inventory-'));
66
+ const filename = join(directory, 'report.json');
67
+ const script = join(__dirname, 'timezone-inventory.cjs');
68
+ const run = () => spawnSync(process.execPath, [script, filename], { encoding: 'utf8' });
69
+ try {
70
+ assert.equal(run().status, 1);
71
+ for (const contents of ['{', JSON.stringify({ examples: [] }), JSON.stringify({
72
+ examples: complete().examples.map(example => ({ ...example, status: 'pending' })),
73
+ })]) {
74
+ writeFileSync(filename, contents);
75
+ const result = run();
76
+ assert.equal(result.status, 1);
77
+ assert.match(result.stderr, /Timezone inventory validation failed/);
78
+ }
79
+ writeFileSync(filename, JSON.stringify(complete()));
80
+ const result = run();
81
+ assert.equal(result.status, 0);
82
+ assert.match(result.stdout, /Verified .* passed timezone integration examples/);
83
+ } finally {
84
+ rmSync(directory, { recursive: true, force: true });
85
+ }
86
+ });
@@ -0,0 +1,124 @@
1
+ name: Checks
2
+
3
+ on:
4
+ workflow_call:
5
+
6
+ permissions:
7
+ contents: read
8
+
9
+ jobs:
10
+ build:
11
+ name: build_on_ruby_${{ matrix.ruby }}
12
+ runs-on: ubuntu-24.04
13
+ timeout-minutes: 20
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ ruby: ['3.3', '3.4', '4.0']
18
+ container:
19
+ image: cimg/ruby:${{ matrix.ruby }}
20
+ options: --user 0
21
+ services:
22
+ redis:
23
+ image: redis:latest@sha256:97ec88159e6d7cfb5ddef9015386087d58a912de80112f06ebdfa78fa5989901
24
+ options: >-
25
+ --health-cmd "redis-cli ping"
26
+ --health-interval 5s
27
+ --health-timeout 3s
28
+ --health-retries 10
29
+ defaults:
30
+ run:
31
+ shell: bash
32
+ env:
33
+ BUNDLE_FROZEN: 'true'
34
+ BUNDLE_PATH: vendor/bundle
35
+ SIMPLECOV_COMMAND_NAME: ruby-${{ matrix.ruby }}
36
+ steps:
37
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
38
+ with:
39
+ persist-credentials: false
40
+
41
+ - name: Install locked dependencies
42
+ run: |
43
+ # cimg's Bash HOME differs from the checkout action's temporary HOME.
44
+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
45
+ git ls-files --error-unmatch lib/redis-objects-periodical.rb >/dev/null
46
+ gem install bundler -v 4.0.23 --no-document
47
+ bundle _4.0.23_ install
48
+
49
+ - name: Execute RSpec
50
+ run: |
51
+ mkdir -p test-results
52
+ bundle exec rspec --format documentation \
53
+ --format RspecJunitFormatter \
54
+ --out test-results/rspec.xml
55
+
56
+ - name: Build gem
57
+ run: bundle exec rake build
58
+
59
+ - name: Verify packaged Ruby sources
60
+ run: |
61
+ bundle exec ruby -rrubygems/package -e '
62
+ package = Gem::Package.new(Dir["pkg/*.gem"].fetch(0)).spec
63
+ sources = Dir["lib/**/*.rb"]
64
+ missing = sources - package.files
65
+ abort("Missing packaged sources: #{missing.join(", ")}") unless missing.empty?
66
+ puts "Verified #{sources.length} Ruby sources in #{package.full_name}"
67
+ '
68
+
69
+ - name: Prepare coverage merge input
70
+ if: ${{ always() && hashFiles('coverage/.resultset.json') != '' }}
71
+ run: cp coverage/.resultset.json coverage/resultset.json
72
+
73
+ - name: Store coverage merge input
74
+ if: ${{ always() && hashFiles('coverage/resultset.json') != '' }}
75
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
76
+ with:
77
+ name: simplecov-ruby-${{ matrix.ruby }}
78
+ path: coverage/resultset.json
79
+ if-no-files-found: error
80
+ retention-days: 14
81
+
82
+ - name: Store test results and coverage
83
+ if: ${{ always() }}
84
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
85
+ with:
86
+ name: test-results-ruby-${{ matrix.ruby }}
87
+ path: |
88
+ test-results/
89
+ coverage/
90
+ retention-days: 14
91
+
92
+ - name: Store gem
93
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
94
+ with:
95
+ name: gem-ruby-${{ matrix.ruby }}
96
+ path: pkg/*.gem
97
+ if-no-files-found: error
98
+ retention-days: 14
99
+
100
+ rubocop:
101
+ name: rubocop
102
+ runs-on: ubuntu-24.04
103
+ timeout-minutes: 15
104
+ container:
105
+ image: cimg/ruby:3.3
106
+ options: --user 0
107
+ defaults:
108
+ run:
109
+ shell: bash
110
+ env:
111
+ BUNDLE_FROZEN: 'true'
112
+ BUNDLE_PATH: vendor/bundle
113
+ steps:
114
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
115
+ with:
116
+ persist-credentials: false
117
+ - name: Install locked dependencies
118
+ run: |
119
+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
120
+ git ls-files --error-unmatch lib/redis-objects-periodical.rb >/dev/null
121
+ gem install bundler -v 4.0.23 --no-document
122
+ bundle _4.0.23_ install
123
+ - name: Run RuboCop
124
+ run: bundle exec rubocop