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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be8af0088e6ff4a0d8e8e26185acd940452d08f123b1820eef198f636db7b1f7
4
- data.tar.gz: 3cd7b2fbb4ea2fd2bd6e53c3d0798ec4bb1e93b89760a4a6fec48b0215fc072e
3
+ metadata.gz: 4ce017da5d08688b7f71334c77d3a1c6a8db2b6029a5be69924b0e62c6948c0b
4
+ data.tar.gz: cf73b3a83c8ac045f9800af654e048d4290a7d3732e2a8de2da71a7e2b354a22
5
5
  SHA512:
6
- metadata.gz: 284c3aca951719e4efd85195dbd6457061732a1507821b173cf8e61e879e75fc751694b2540b6716b087ec311ad71c3c9e6d59a29f46f25552e0ae5586baf715
7
- data.tar.gz: 5fafbcc175c1303535e6e23ee9df14f41a1db5006515416171f15e73f38ee5192e6a0b9adfa6fdfb1ef94c351768bd543c7d9b08e1ce8d1e1de0ac84f621ae1b
6
+ metadata.gz: 5c44b80f46477aab2384b225a209a26373a357589e21b651556aab74926aa0369d945a6e6357f85600144b2cc2517ada7b2e6b17dd7ecf037a2fae3cd6441657
7
+ data.tar.gz: 0c5296dc39927ba464aacf5586de4c2810980d1e4e38c4c2a35e52ce5a711e8858213265d0798c3791ebb01d870e0d8eb1f06d32a5f9008c82bea21f9c5b0c57
@@ -0,0 +1,141 @@
1
+ ---
2
+ name: dependabot-batch-maintenance
3
+ description: Review Dependabot pull requests in this repository and merge an authorized batch one at a time, preserving exclusions and checking compatibility, refreshed branches, current-head CI, and integration results. Use for review-only requests without performing mutations.
4
+ ---
5
+
6
+ # Dependabot batch maintenance
7
+
8
+ ## Scope and authorization
9
+
10
+ - Establish the user's targets, exclusions, and holds before starting.
11
+ - For review-only requests, do not post comments, update branches, or merge.
12
+ - When batch merging is authorized, continue through eligible PRs without asking
13
+ for confirmation for each PR.
14
+ - Preserve an excluded update even if Dependabot recreates it under a new PR
15
+ number. Match exclusions by dependency and update, not only by PR number.
16
+ - Releases, support-policy changes, and GitHub protection changes are outside
17
+ this workflow unless separately authorized.
18
+
19
+ ## Repository sources
20
+
21
+ Read the current versions of these sources at the start:
22
+
23
+ - [AGENTS.md](../../../AGENTS.md): checkout, compatibility, and validation rules.
24
+ - [CONTRIBUTING.md](../../../CONTRIBUTING.md): development and verification commands.
25
+ - [Maintaining](../../../docs/maintaining.md#dependency-updates-and-auto-merge):
26
+ dependency update and auto-merge procedures.
27
+ - [Workflows](../../../.github/workflows/) and
28
+ [Dependabot policy](../../../.github/scripts/dependabot-policy.cjs): actual
29
+ check configuration and automated merge eligibility.
30
+ - Current GitHub branch protection, effective rulesets, and required checks.
31
+
32
+ Do not hard-code check names, supported Ruby versions, dependency versions, or
33
+ PR numbers in this skill. Derive them from the current sources above.
34
+
35
+ An update outside the automated merge allowlist can still pass individual
36
+ review. Major updates require compatibility review rather than automatic
37
+ rejection. Return decisions about support policy or public compatibility to the
38
+ user when they are outside the authorized scope or remain unresolved.
39
+
40
+ ## Establish the batch
41
+
42
+ 1. Check the checkout, branch, Git status, and worktree list.
43
+ 2. List open Dependabot PRs and verify their actual author and update targets.
44
+ 3. Separate candidates, exclusions, and holds.
45
+ 4. For each candidate, record its PR number, old and new versions, head SHA,
46
+ base SHA, changed files, CI results, and existing reviews.
47
+
48
+ Distinguish replacements for original candidates from unrelated PRs created
49
+ after the batch started. Review replacements within the original scope and
50
+ preserve exclusions. Report unrelated new updates rather than extending the
51
+ batch indefinitely.
52
+
53
+ ## Review each PR
54
+
55
+ Process one PR at a time.
56
+
57
+ ### Inspect the update
58
+
59
+ - Read official release notes and changelogs, and inspect upstream diffs when
60
+ needed. Compare breaking changes with actual usage in this repository.
61
+ - Review the complete PR diff for unrelated edits, unexpected dependency
62
+ updates, and changes to constraints, platforms, or sources.
63
+ - For Bundler updates, inspect Ruby requirements and transitive dependencies.
64
+ When a shared dependency appears in both the root and optional ActiveSupport
65
+ environments, verify consistency across both lockfiles.
66
+ - For Actions updates, verify that the pinned SHA corresponds to the intended
67
+ official release. Review the Node runtime, inputs, outputs, script APIs,
68
+ permissions, triggers, and executable code affected by the update.
69
+
70
+ ### Refresh a stale branch
71
+
72
+ - Do not treat CI against an old base as validation against current master.
73
+ - If a refresh is needed, use one update mechanism at a time.
74
+ - After requesting `@dependabot rebase`, wait for the head to change or for
75
+ Dependabot to report the outcome. Do not immediately also run
76
+ `gh pr update-branch` or post another refresh request.
77
+ - Space status reads apart. If the operation remains pending, preserve that
78
+ state and report it rather than repeatedly requesting the same update.
79
+ - Use `@dependabot recreate` when rebase has failed and regeneration is
80
+ appropriate. Check for manual changes before allowing recreation to overwrite
81
+ them; skip if their disposition is unclear.
82
+ - Bound retries: if one rebase attempt and one appropriate recreation attempt
83
+ do not resolve the problem, skip the PR and record the reason.
84
+
85
+ Whenever the head changes, re-inspect the complete diff and obtain verification
86
+ results for the new head. Do not reuse approval based on a previous head.
87
+
88
+ ### Verify and merge
89
+
90
+ - Use validation results associated with the current head and current base.
91
+ - Check required statuses and additional checks relevant to the update. Wait for
92
+ downstream jobs, including coverage collation, to finish.
93
+ - Do not count pending, failed, unknown, or unexpectedly skipped checks as
94
+ successful. Investigate expected skips before describing what was verified.
95
+ - Validate workflow changes with actionlint and zizmor. Compare existing
96
+ findings with the base, distinguish new findings, and assess their relevance;
97
+ an unchanged finding count alone is not proof of safety.
98
+ - Do not merge with unresolved review findings or compatibility concerns.
99
+ - Immediately before merging, re-read the open state, head SHA, base, all
100
+ relevant checks, and `mergeStateStatus: CLEAN`.
101
+ - Merge with the inspected head SHA as an expected-head guard, using the
102
+ repository's configured merge method. Confirm that GitHub reports success.
103
+ - If the expected head no longer matches, read the new state and review it
104
+ rather than resending the same merge request. Never bypass protection or use
105
+ an admin override to complete the batch.
106
+
107
+ ## Skip or stop
108
+
109
+ Skip an update with a PR-specific problem and continue with independent
110
+ candidates. Return the decision to the user when:
111
+
112
+ - A public compatibility or support-policy change is needed.
113
+ - CI failures or security concerns cannot be resolved within scope.
114
+ - Unrelated edits are present and their intent cannot be established.
115
+ - Required verification results cannot be obtained.
116
+
117
+ If authentication or API failures prevent reliable review of the whole batch,
118
+ stop merging. Report completed and uncompleted work separately.
119
+
120
+ ## Integration and resumption
121
+
122
+ - After each merge, check master CI. If integration fails, stop further merges
123
+ and report the impact. Determine the cause and authorization before fixing,
124
+ reverting, or rerunning anything.
125
+ - Verify the final master commit's CI before reporting the batch complete.
126
+ - On resumption, retrieve the current PR state, head, base, and CI results
127
+ again. Do not rely on the pre-interruption snapshot.
128
+
129
+ ## Completion report
130
+
131
+ Summarize:
132
+
133
+ - Each PR's update and its merged, skipped, excluded, or held disposition.
134
+ - Reasons for skips and decisions that require the user's judgment.
135
+ - The final master SHA and CI link.
136
+ - Verification performed and items that remain unverified.
137
+ - Documentation and implementation inconsistencies encountered during the task.
138
+ - A short Keep / Problem / Try retrospective.
139
+
140
+ Distinguish successful coverage collation from successful delivery to an
141
+ external service, and local test success from CI matrix success.
@@ -1,11 +1,24 @@
1
1
  version: 2
2
2
  updates:
3
3
  - package-ecosystem: bundler
4
- directory: "/"
4
+ directories:
5
+ - "/"
6
+ - "/gemfiles/active_support"
7
+ groups:
8
+ shared-dependencies:
9
+ patterns:
10
+ - "*"
11
+ group-by: dependency-name
5
12
  schedule:
6
- interval: daily
13
+ interval: weekly
14
+ day: monday
7
15
  time: "08:00"
8
- timezone: Japan
16
+ timezone: Asia/Tokyo
17
+ cooldown:
18
+ default-days: 7
19
+ semver-major-days: 14
20
+ semver-minor-days: 7
21
+ semver-patch-days: 3
9
22
  open-pull-requests-limit: 10
10
23
  reviewers:
11
24
  - ryz310
@@ -17,3 +30,19 @@ updates:
17
30
  - dependency-name: simplecov
18
31
  versions:
19
32
  - ">= 0.19.a, < 0.20"
33
+ - package-ecosystem: github-actions
34
+ directory: "/"
35
+ schedule:
36
+ interval: weekly
37
+ day: monday
38
+ time: "08:00"
39
+ timezone: Asia/Tokyo
40
+ cooldown:
41
+ default-days: 7
42
+ open-pull-requests-limit: 10
43
+ reviewers:
44
+ - ryz310
45
+ assignees:
46
+ - ryz310
47
+ labels:
48
+ - dependabot
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ const { evaluate } = require('./dependabot-policy.cjs');
4
+ const workflowPath = '.github/workflows/dependabot-auto-merge.yml';
5
+ const markerPrefix = 'Dependabot auto-merge policy reservation\n\n';
6
+
7
+ function reservationBody(context) {
8
+ return markerPrefix + JSON.stringify({
9
+ version: 1, workflow: workflowPath,
10
+ repository: `${context.repo.owner}/${context.repo.repo}`,
11
+ number: context.payload.pull_request.number,
12
+ head: context.payload.pull_request.head.sha,
13
+ run: String(context.runId),
14
+ });
15
+ }
16
+
17
+ async function readState(github, context) {
18
+ const result = await github.graphql(`query($owner: String!, $repo: String!, $number: Int!) {
19
+ repository(owner: $owner, name: $repo) {
20
+ pullRequest(number: $number) {
21
+ id state headRefOid baseRefName mergeStateStatus
22
+ autoMergeRequest { enabledAt enabledBy { login __typename } commitBody mergeMethod }
23
+ }
24
+ }
25
+ }`, { ...context.repo, number: context.payload.pull_request.number });
26
+ const pr = result.repository && result.repository.pullRequest;
27
+ if (!pr || !pr.id || !pr.headRefOid || !Object.hasOwn(pr, 'autoMergeRequest')) {
28
+ throw new Error('Cannot verify current auto-merge state; inspect the PR manually.');
29
+ }
30
+ return pr;
31
+ }
32
+
33
+ function validOwnershipMarker(marker, context) {
34
+ const matchesWorkflow = marker.version === 1 && marker.workflow === workflowPath;
35
+ const matchesPullRequest = marker.repository === `${context.repo.owner}/${context.repo.repo}` &&
36
+ marker.number === context.payload.pull_request.number;
37
+ const validIdentity = /^[a-f0-9]{40}$/.test(marker.head) && /^[1-9][0-9]*$/.test(marker.run);
38
+ return matchesWorkflow && matchesPullRequest && validIdentity;
39
+ }
40
+
41
+ async function ownedReservation(github, context, reservation) {
42
+ if (!reservation) return false;
43
+ if (reservation.enabledBy?.login !== 'github-actions[bot]' ||
44
+ reservation.enabledBy.__typename !== 'Bot' ||
45
+ !reservation.commitBody?.startsWith(markerPrefix)) return false;
46
+ let marker;
47
+ try { marker = JSON.parse(reservation.commitBody.slice(markerPrefix.length)); }
48
+ catch { return false; }
49
+ if (!validOwnershipMarker(marker, context)) return false;
50
+ // GITHUB_TOKEN is shared across workflows: verify the marker's run provenance too.
51
+ const { data: run } = await github.rest.actions.getWorkflowRun({
52
+ ...context.repo, run_id: marker.run,
53
+ });
54
+ return String(run.id) === marker.run && run.path === workflowPath &&
55
+ run.event === 'pull_request_target' &&
56
+ run.repository?.full_name === marker.repository;
57
+ }
58
+
59
+ function sameReservation(left, right) {
60
+ return JSON.stringify(left) === JSON.stringify(right);
61
+ }
62
+
63
+ async function revoke({ github, context, core, state }) {
64
+ if (!state.autoMergeRequest) return;
65
+ if (!await ownedReservation(github, context, state.autoMergeRequest)) {
66
+ core.notice('Preserving an unowned or legacy auto-merge reservation. Inspect it manually before merging.');
67
+ return;
68
+ }
69
+ // No conditional-disable mutation exists. Serialize this workflow's runs and
70
+ // re-read immediately before mutation; external writers must not race recovery.
71
+ const current = await readState(github, context);
72
+ if (!current.autoMergeRequest) return;
73
+ if (current.headRefOid !== context.payload.pull_request.head.sha ||
74
+ !sameReservation(current.autoMergeRequest, state.autoMergeRequest)) {
75
+ throw new Error('PR head or reservation changed before cancellation; inspect auto-merge manually.');
76
+ }
77
+ await github.graphql(`mutation($id: ID!) {
78
+ disablePullRequestAutoMerge(input: {pullRequestId: $id}) {
79
+ pullRequest { id autoMergeRequest { enabledAt } }
80
+ }
81
+ }`, { id: current.id });
82
+ const after = await readState(github, context);
83
+ if (after.autoMergeRequest) {
84
+ throw new Error('Auto-merge cancellation was not confirmed; disable auto-merge manually.');
85
+ }
86
+ core.notice('Revoked this workflow\'s auto-merge reservation.');
87
+ }
88
+
89
+ function cleanStatusError(error) {
90
+ // Octokit preserves the structured GraphQL errors separately from Error.message.
91
+ if (error.name !== 'GraphqlResponseError' || !Array.isArray(error.errors) ||
92
+ error.errors.length !== 1) return false;
93
+ const failure = error.errors[0];
94
+ return failure.type === 'UNPROCESSABLE' &&
95
+ JSON.stringify(failure.path) === JSON.stringify(['enablePullRequestAutoMerge']) &&
96
+ failure.message === 'Pull request Pull request is in clean status';
97
+ }
98
+
99
+ async function mergeCleanUpdate({ github, context, core, metadata }) {
100
+ // Repeat the entire policy/protection check; the PR base can change without a
101
+ // new head OID. A fresh final snapshot also preserves newly created reservations.
102
+ if (!await evaluate({ github, context, core, metadata })) {
103
+ throw new Error('Policy changed after the clean-status response; inspect the PR manually.');
104
+ }
105
+ const current = await readState(github, context);
106
+ const sameTarget = current.headRefOid === context.payload.pull_request.head.sha &&
107
+ current.baseRefName === 'master';
108
+ const ready = current.state === 'OPEN' && current.mergeStateStatus === 'CLEAN';
109
+ if (!sameTarget || !ready || current.autoMergeRequest) {
110
+ throw new Error('PR state changed before the clean-status merge; inspect the PR manually.');
111
+ }
112
+ const result = await github.graphql(`mutation($id: ID!, $head: GitObjectID!) {
113
+ mergePullRequest(input: {pullRequestId: $id, expectedHeadOid: $head, mergeMethod: MERGE}) {
114
+ pullRequest { id headRefOid merged }
115
+ }
116
+ }`, { id: current.id, head: current.headRefOid });
117
+ const merged = result.mergePullRequest?.pullRequest;
118
+ if (merged?.merged !== true || merged.headRefOid !== current.headRefOid) {
119
+ throw new Error('Could not confirm the clean-status merge; inspect the PR manually.');
120
+ }
121
+ core.notice('Merged the inspected head after all required checks passed.');
122
+ }
123
+
124
+ async function reconcileState({ github, context, core, metadata, metadataOutcome }) {
125
+ const state = await readState(github, context);
126
+ if (state.headRefOid !== context.payload.pull_request.head.sha) {
127
+ core.notice('Stale event: preserving the current head\'s auto-merge state.');
128
+ return;
129
+ }
130
+ let safe = false;
131
+ let verificationError;
132
+ try {
133
+ if (metadataOutcome !== 'success') throw new Error('Dependabot metadata retrieval failed.');
134
+ safe = await evaluate({ github, context, core, metadata });
135
+ } catch (error) { verificationError = error; }
136
+
137
+ if (!safe) {
138
+ await revoke({ github, context, core, state });
139
+ if (verificationError) {
140
+ throw new Error(`Policy verification failed; check the PR's auto-merge state: ${verificationError.message}`);
141
+ }
142
+ core.notice('Update requires manual review.');
143
+ return;
144
+ }
145
+
146
+ const current = await readState(github, context);
147
+ if (current.headRefOid !== context.payload.pull_request.head.sha || current.state !== 'OPEN') {
148
+ core.notice('PR changed during verification; no reservation created.');
149
+ return;
150
+ }
151
+ if (current.autoMergeRequest) {
152
+ core.notice('Preserving the existing auto-merge reservation.');
153
+ return;
154
+ }
155
+ let result;
156
+ try {
157
+ result = await github.graphql(`mutation($id: ID!, $head: GitObjectID!, $body: String!) {
158
+ enablePullRequestAutoMerge(input: {
159
+ pullRequestId: $id, expectedHeadOid: $head, mergeMethod: MERGE, commitBody: $body
160
+ }) { pullRequest { id headRefOid autoMergeRequest { commitBody } } }
161
+ }`, { id: current.id, head: current.headRefOid, body: reservationBody(context) });
162
+ } catch (error) {
163
+ if (!cleanStatusError(error)) throw error;
164
+ await mergeCleanUpdate({ github, context, core, metadata });
165
+ return;
166
+ }
167
+ const enabled = result.enablePullRequestAutoMerge?.pullRequest;
168
+ if (enabled?.headRefOid !== current.headRefOid ||
169
+ enabled.autoMergeRequest?.commitBody !== reservationBody(context)) {
170
+ throw new Error('Could not verify the new reservation; inspect auto-merge manually.');
171
+ }
172
+ core.notice('Reserved auto-merge for the inspected head.');
173
+ }
174
+
175
+ async function reconcile(input) {
176
+ try { await reconcileState(input); }
177
+ catch (error) {
178
+ throw new Error(`Auto-merge reconciliation failed; inspect the PR and disable an unsafe reservation manually: ${error.message}`);
179
+ }
180
+ }
181
+
182
+ module.exports = { reconcile, reservationBody, ownedReservation };
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const { reconcile, reservationBody, ownedReservation } = require('./dependabot-auto-merge.cjs');
5
+ const { requiredChecks } = require('./dependabot-policy.cjs');
6
+
7
+ function harness() {
8
+ const context = { repo: { owner: 'ryz310', repo: 'redis-objects-periodical' }, runId: 123,
9
+ payload: { pull_request: { number: 1, head: { sha: 'a'.repeat(40) } } } };
10
+ const reservation = { enabledAt: '2026-10-27T00:00:00Z',
11
+ enabledBy: { login: 'github-actions[bot]', __typename: 'Bot' },
12
+ commitBody: reservationBody(context), mergeMethod: 'MERGE' };
13
+ const state = { id: 'PR_node', state: 'OPEN', headRefOid: 'a'.repeat(40),
14
+ baseRefName: 'master', mergeStateStatus: 'BLOCKED', autoMergeRequest: reservation };
15
+ const calls = [], notices = [], states = [];
16
+ const pr = { number: 1, user: { login: 'dependabot[bot]' }, state: 'open', draft: false,
17
+ base: { ref: 'master', sha: 'base', repo: { full_name: 'ryz310/redis-objects-periodical' } },
18
+ head: { sha: state.headRefOid, repo: { full_name: 'ryz310/redis-objects-periodical' } }, changed_files: 1 };
19
+ const run = { id: 123, path: '.github/workflows/dependabot-auto-merge.yml',
20
+ event: 'pull_request_target', repository: { full_name: 'ryz310/redis-objects-periodical' } };
21
+ const github = {
22
+ rest: { pulls: { get: async () => ({ data: pr }), listFiles: 'files' },
23
+ actions: { getWorkflowRun: async () => ({ data: run }) } },
24
+ paginate: async endpoint => endpoint === 'files' ? [{ filename: '.github/workflows/ci.yml', status: 'modified',
25
+ additions: 1, deletions: 1, patch: '@@ -1 +1 @@\n- uses: actions/checkout@' + 'a'.repeat(40) +
26
+ '\n+ uses: actions/checkout@' + 'b'.repeat(40) }] : [],
27
+ graphql: async (query, variables) => {
28
+ if (query.includes('mergePullRequest')) {
29
+ assert.match(query, /expectedHeadOid: \$head, mergeMethod: MERGE/);
30
+ calls.push(['merge', variables]); state.state = 'MERGED';
31
+ return { mergePullRequest: { pullRequest: { ...state, merged: true } } };
32
+ }
33
+ if (query.includes('disablePullRequestAutoMerge')) {
34
+ calls.push(['disable', variables]); state.autoMergeRequest = null;
35
+ return { disablePullRequestAutoMerge: { pullRequest: state } };
36
+ }
37
+ if (query.includes('enablePullRequestAutoMerge')) {
38
+ calls.push(['enable', variables]);
39
+ state.autoMergeRequest = { ...reservation, commitBody: variables.body };
40
+ return { enablePullRequestAutoMerge: { pullRequest: state } };
41
+ }
42
+ if (query.includes('autoMergeRequest')) return { repository: { pullRequest: states.shift() || structuredClone(state) } };
43
+ return { repository: { autoMergeAllowed: true, ref: { branchProtectionRule: {
44
+ requiresStatusChecks: true, requiresStrictStatusChecks: true, requiredStatusCheckContexts: requiredChecks,
45
+ } } } };
46
+ },
47
+ };
48
+ return { github, context, core: { notice: message => notices.push(message) },
49
+ metadata: { ecosystem: 'github_actions', updateType: 'version-update:semver-major', dependencyNames: 'actions/checkout' },
50
+ metadataOutcome: 'success', state, reservation, states, pr, run, calls, notices };
51
+ }
52
+
53
+ test('revokes an owned reservation when the current-head update is rejected', async () => {
54
+ const input = harness(); await reconcile(input);
55
+ assert.deepEqual(input.calls, [['disable', { id: 'PR_node' }]]);
56
+ assert.equal(input.state.autoMergeRequest, null);
57
+ });
58
+ for (const scenario of ['no-reservation', 'already-disabled']) {
59
+ test(`rejection tolerates ${scenario}`, async () => {
60
+ const input = harness();
61
+ if (scenario === 'no-reservation') input.state.autoMergeRequest = null;
62
+ else input.states.push(structuredClone(input.state), { ...input.state, autoMergeRequest: null });
63
+ await reconcile(input); assert.deepEqual(input.calls, []);
64
+ });
65
+ }
66
+ for (const scenario of ['human', 'other-workflow', 'legacy', 'wrong-repo', 'wrong-pr', 'malformed', 'wrong-event', 'wrong-run']) {
67
+ test(`preserves ${scenario} reservations`, async () => {
68
+ const input = harness();
69
+ if (scenario === 'human') input.reservation.enabledBy = { login: 'maintainer', __typename: 'User' };
70
+ if (scenario === 'other-workflow') input.run.path = '.github/workflows/another.yml';
71
+ if (scenario === 'legacy') input.reservation.commitBody = 'Existing merge message';
72
+ if (scenario === 'wrong-repo') input.reservation.commitBody = reservationBody({ ...input.context, repo: { owner: 'other', repo: 'repo' } });
73
+ if (scenario === 'wrong-pr') input.reservation.commitBody = reservationBody({ ...input.context,
74
+ payload: { pull_request: { number: 2, head: { sha: 'a'.repeat(40) } } } });
75
+ if (scenario === 'malformed') input.reservation.commitBody = 'Dependabot auto-merge policy reservation\n\n{';
76
+ if (scenario === 'wrong-event') input.run.event = 'push';
77
+ if (scenario === 'wrong-run') input.run.id = 124;
78
+ await reconcile(input); assert.deepEqual(input.calls, []);
79
+ assert.ok(input.notices.some(message => message.includes('Inspect it manually')));
80
+ });
81
+ }
82
+ for (const scenario of ['metadata', 'evaluation']) {
83
+ test(`revokes on ${scenario} failure and still fails visibly`, async () => {
84
+ const input = harness();
85
+ if (scenario === 'metadata') input.metadataOutcome = 'failure';
86
+ else input.github.paginate = async () => { throw new Error('API unavailable'); };
87
+ await assert.rejects(reconcile(input), /Policy verification failed/);
88
+ assert.equal(input.calls[0][0], 'disable');
89
+ });
90
+ }
91
+ test('stale events preserve a newer successfully evaluated reservation', async () => {
92
+ const input = harness(); input.state.headRefOid = 'b'.repeat(40);
93
+ input.state.autoMergeRequest.commitBody = reservationBody({ ...input.context,
94
+ payload: { pull_request: { number: 1, head: { sha: 'b'.repeat(40) } } } });
95
+ input.github.paginate = async () => { throw new Error('Must not evaluate stale event'); };
96
+ await reconcile(input); assert.deepEqual(input.calls, []);
97
+ });
98
+ for (const scenario of ['head', 'reservation']) {
99
+ test(`refuses to cancel after concurrent ${scenario} change`, async () => {
100
+ const input = harness(); const changed = structuredClone(input.state);
101
+ if (scenario === 'head') changed.headRefOid = 'b'.repeat(40);
102
+ else changed.autoMergeRequest.enabledAt = '2026-10-27T01:00:00Z';
103
+ input.states.push(structuredClone(input.state), changed);
104
+ await assert.rejects(reconcile(input), /changed before cancellation/);
105
+ assert.deepEqual(input.calls, []);
106
+ });
107
+ }
108
+ for (const scenario of ['cancellation-error', 'cancellation-unconfirmed', 'provenance-error', 'initial-read-error', 'verification-read-error']) {
109
+ test(`fails visibly on ${scenario}`, async () => {
110
+ const input = harness(); const graphql = input.github.graphql;
111
+ if (scenario === 'provenance-error') input.github.rest.actions.getWorkflowRun = async () => { throw new Error('Forbidden'); };
112
+ else input.github.graphql = async (query, variables) => {
113
+ if (scenario === 'cancellation-error' && query.includes('disablePullRequestAutoMerge')) throw new Error('Forbidden');
114
+ if (scenario === 'cancellation-unconfirmed' && query.includes('disablePullRequestAutoMerge')) return {};
115
+ if (scenario === 'initial-read-error') throw new Error('Forbidden');
116
+ if (scenario === 'verification-read-error' && input.calls.length) throw new Error('Forbidden');
117
+ return graphql(query, variables);
118
+ };
119
+ await assert.rejects(reconcile(input), /Forbidden|cancellation was not confirmed/);
120
+ });
121
+ }
122
+ test('safe updates reserve with an atomic expected-head guard and ownership body', async () => {
123
+ const input = harness(); input.state.autoMergeRequest = null;
124
+ input.metadata.updateType = 'version-update:semver-patch';
125
+ await reconcile(input);
126
+ assert.deepEqual(input.calls, [['enable', { id: 'PR_node', head: 'a'.repeat(40), body: reservationBody(input.context) }]]);
127
+ });
128
+ test('safe updates preserve existing reservations including human-owned ones', async () => {
129
+ const input = harness(); input.metadata.updateType = 'version-update:semver-patch';
130
+ input.reservation.enabledBy = { login: 'maintainer', __typename: 'User' };
131
+ await reconcile(input); assert.deepEqual(input.calls, []);
132
+ });
133
+ test('head change during a successful evaluation cannot enable a reservation', async () => {
134
+ const input = harness(); input.state.autoMergeRequest = null;
135
+ input.metadata.updateType = 'version-update:semver-patch';
136
+ input.states.push(structuredClone(input.state), { ...input.state, headRefOid: 'b'.repeat(40) });
137
+ await reconcile(input); assert.deepEqual(input.calls, []);
138
+ });
139
+ test('ownership requires workflow run provenance beyond the shared bot identity', async () => {
140
+ const input = harness();
141
+ assert.equal(await ownedReservation(input.github, input.context, input.reservation), true);
142
+ input.run.repository.full_name = 'other/repo';
143
+ assert.equal(await ownedReservation(input.github, input.context, input.reservation), false);
144
+ });
145
+
146
+ for (const scenario of ['enable-error', 'null-response', 'missing-reservation', 'wrong-head']) {
147
+ test(`fails visibly on ${scenario} after an eligible evaluation`, async () => {
148
+ const input = harness(); input.state.autoMergeRequest = null;
149
+ input.metadata.updateType = 'version-update:semver-patch';
150
+ const graphql = input.github.graphql;
151
+ input.github.graphql = async (query, variables) => {
152
+ if (!query.includes('enablePullRequestAutoMerge')) return graphql(query, variables);
153
+ if (scenario === 'enable-error') throw new Error('Forbidden');
154
+ if (scenario === 'null-response') return { enablePullRequestAutoMerge: null };
155
+ if (scenario === 'missing-reservation') return { enablePullRequestAutoMerge: { pullRequest: input.state } };
156
+ return { enablePullRequestAutoMerge: { pullRequest: { ...input.state,
157
+ headRefOid: 'b'.repeat(40), autoMergeRequest: input.reservation } } };
158
+ };
159
+ await assert.rejects(reconcile(input), /inspect the PR.*(?:Forbidden|Could not verify)/);
160
+ });
161
+ }
162
+
163
+ test('metadata retrieval failure still fails when no reservation exists', async () => {
164
+ const input = harness(); input.state.autoMergeRequest = null; input.metadataOutcome = 'failure';
165
+ await assert.rejects(reconcile(input), /metadata retrieval failed/);
166
+ assert.deepEqual(input.calls, []);
167
+ });
168
+
169
+ test('privileged workflow keeps trusted-base execution and failure cleanup wired', () => {
170
+ const { readFileSync } = require('node:fs');
171
+ const { join } = require('node:path');
172
+ const workflow = readFileSync(join(__dirname, '../workflows/dependabot-auto-merge.yml'), 'utf8');
173
+ assert.match(workflow, /cancel-in-progress: false/);
174
+ assert.match(workflow, /ref: \$\{\{ github.event.pull_request.base.sha \}\}/);
175
+ assert.doesNotMatch(workflow, /ref:.*pull_request.head/);
176
+ assert.match(workflow, /if: \$\{\{ always\(\) && steps.policy_checkout.outcome == 'success' \}\}/);
177
+ assert.match(workflow, /METADATA_OUTCOME: \$\{\{ steps.metadata.outcome \}\}/);
178
+ assert.match(workflow, /await reconcile\(/);
179
+ });
180
+
181
+
182
+ // Reproduce the reviewer-observed clean-status response in Octokit's structured
183
+ // GraphqlResponseError shape; no live merge or reservation is used by these tests.
184
+ function cleanStatusFailure() {
185
+ const failure = new Error('Request failed due to following response errors:\n - Pull request Pull request is in clean status');
186
+ failure.name = 'GraphqlResponseError';
187
+ failure.errors = [{ type: 'UNPROCESSABLE', path: ['enablePullRequestAutoMerge'],
188
+ message: 'Pull request Pull request is in clean status' }];
189
+ return failure;
190
+ }
191
+
192
+ function cleanHarness(failure = cleanStatusFailure(), afterEnable = () => {}) {
193
+ const input = harness(); input.state.autoMergeRequest = null;
194
+ input.state.mergeStateStatus = 'CLEAN';
195
+ input.metadata.updateType = 'version-update:semver-patch';
196
+ const graphql = input.github.graphql;
197
+ input.github.graphql = async (query, variables) => {
198
+ if (query.includes('enablePullRequestAutoMerge')) {
199
+ input.calls.push(['enable', variables]);
200
+ afterEnable(input);
201
+ throw failure;
202
+ }
203
+ return graphql(query, variables);
204
+ };
205
+ return input;
206
+ }
207
+
208
+ test('an eligible PR with passing checks falls back to a guarded merge after the exact clean-status error', async () => {
209
+ const input = cleanHarness(); let protectionReads = 0;
210
+ const paginate = input.github.paginate;
211
+ input.github.paginate = async (endpoint, variables) => {
212
+ if (endpoint !== 'files') protectionReads++;
213
+ return paginate(endpoint, variables);
214
+ };
215
+ await reconcile(input);
216
+ assert.equal(protectionReads, 2, 'Verify protection again immediately before the merge');
217
+ assert.deepEqual(input.calls.map(call => call[0]), ['enable', 'merge']);
218
+ assert.deepEqual(input.calls[1][1], { id: 'PR_node', head: 'a'.repeat(40) });
219
+ assert.ok(input.notices.some(message => message.startsWith('Merged the inspected head')));
220
+ });
221
+
222
+ for (const scenario of ['plain-message', 'forbidden', 'network', 'wrong-mutation', 'wrong-type', 'unknown-message', 'mixed-errors']) {
223
+ test(`clean-status fallback does not accept ${scenario}`, async () => {
224
+ let failure = cleanStatusFailure();
225
+ if (scenario === 'plain-message') failure = new Error(failure.errors[0].message);
226
+ if (scenario === 'forbidden') failure.errors[0].type = 'FORBIDDEN';
227
+ if (scenario === 'network') failure.name = 'HttpError';
228
+ if (scenario === 'wrong-mutation') failure.errors[0].path = ['mergePullRequest'];
229
+ if (scenario === 'wrong-type') failure.errors[0].type = 'NOT_FOUND';
230
+ if (scenario === 'unknown-message') failure.errors[0].message = 'Unknown clean-status error';
231
+ if (scenario === 'mixed-errors') failure.errors.push({ type: 'FORBIDDEN', message: 'Forbidden' });
232
+ const input = cleanHarness(failure);
233
+ await assert.rejects(reconcile(input), /Auto-merge reconciliation failed/);
234
+ assert.deepEqual(input.calls.map(call => call[0]), ['enable']);
235
+ });
236
+ }
237
+
238
+ for (const scenario of ['head', 'base', 'closed', 'blocked', 'unknown-status', 'new-human-reservation', 'protection']) {
239
+ test(`clean-status fallback refuses a changed ${scenario}`, async () => {
240
+ const input = cleanHarness(cleanStatusFailure(), current => {
241
+ if (scenario === 'head') current.state.headRefOid = 'b'.repeat(40);
242
+ if (scenario === 'base') current.state.baseRefName = 'release';
243
+ if (scenario === 'closed') current.state.state = 'CLOSED';
244
+ if (scenario === 'blocked') current.state.mergeStateStatus = 'BLOCKED';
245
+ if (scenario === 'unknown-status') current.state.mergeStateStatus = 'UNKNOWN';
246
+ if (scenario === 'new-human-reservation') current.state.autoMergeRequest = {
247
+ ...current.reservation, enabledBy: { login: 'maintainer', __typename: 'User' },
248
+ };
249
+ if (scenario === 'protection') {
250
+ const graphql = current.github.graphql;
251
+ current.github.graphql = async (query, variables) => query.includes('autoMergeAllowed') ?
252
+ { repository: { autoMergeAllowed: false } } : graphql(query, variables);
253
+ }
254
+ });
255
+ await assert.rejects(reconcile(input), /Policy changed|PR state changed/);
256
+ assert.deepEqual(input.calls.map(call => call[0]), ['enable']);
257
+ });
258
+ }
259
+
260
+ test('retargeting during clean-status fallback is rejected by the repeated policy evaluation', async () => {
261
+ const input = cleanHarness(cleanStatusFailure(), current => { current.pr.base.ref = 'release'; });
262
+ await assert.rejects(reconcile(input), /Policy changed/);
263
+ assert.deepEqual(input.calls.map(call => call[0]), ['enable']);
264
+ });
265
+
266
+ for (const scenario of ['api-error', 'not-merged', 'wrong-head', 'missing-response']) {
267
+ test(`clean-status merge fails visibly on ${scenario}`, async () => {
268
+ const input = cleanHarness(); const graphql = input.github.graphql;
269
+ input.github.graphql = async (query, variables) => {
270
+ if (!query.includes('mergePullRequest')) return graphql(query, variables);
271
+ if (scenario === 'api-error') throw new Error('Head or protection changed');
272
+ if (scenario === 'missing-response') return { mergePullRequest: null };
273
+ return { mergePullRequest: { pullRequest: { merged: scenario !== 'not-merged',
274
+ headRefOid: scenario === 'wrong-head' ? 'b'.repeat(40) : input.state.headRefOid } } };
275
+ };
276
+ await assert.rejects(reconcile(input), /Head or protection changed|Could not confirm/);
277
+ });
278
+ }