redis-objects-periodical 0.6.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.
- checksums.yaml +4 -4
- data/.agents/skills/dependabot-batch-maintenance/SKILL.md +141 -0
- data/.github/dependabot.yml +32 -3
- data/.github/scripts/dependabot-auto-merge.cjs +182 -0
- data/.github/scripts/dependabot-auto-merge.test.cjs +278 -0
- data/.github/scripts/dependabot-policy.cjs +132 -0
- data/.github/scripts/dependabot-policy.test.cjs +235 -0
- data/.github/scripts/timezone-inventory.cjs +48 -0
- data/.github/scripts/timezone-inventory.test.cjs +86 -0
- data/.github/workflows/checks.yml +124 -0
- data/.github/workflows/ci.yml +191 -0
- data/.github/workflows/dependabot-auto-merge.yml +61 -0
- data/.github/workflows/release.yml +59 -0
- data/.github/workflows/rubocop_challenge.yml +71 -0
- data/.rspec +1 -0
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +3 -2
- data/AGENTS.md +133 -0
- data/CHANGELOG.md +87 -0
- data/CONTRIBUTING.md +203 -0
- data/Dockerfile +3 -2
- data/Gemfile +6 -6
- data/Gemfile.lock +71 -66
- data/README.md +58 -180
- data/docker-compose.yml +1 -3
- data/docs/maintaining.md +237 -0
- data/docs/usage.md +216 -0
- data/docs/verification/2026-10-27-timezone.md +25 -0
- data/gemfiles/active_support/Gemfile +5 -0
- data/gemfiles/active_support/Gemfile.lock +137 -0
- data/lib/redis/base_value_object.rb +27 -0
- data/lib/redis/objects/periodical/version.rb +1 -1
- data/lib/redis/objects/periodical_counters.rb +1 -1
- data/lib/redis/objects/periodical_hashes.rb +1 -1
- data/lib/redis/objects/periodical_sets.rb +1 -1
- data/lib/redis/objects/periodical_values.rb +44 -0
- data/lib/redis/periodical_counter.rb +1 -1
- data/lib/redis/periodical_hash_key.rb +1 -1
- data/lib/redis/periodical_set.rb +1 -1
- data/lib/redis/periodical_value.rb +15 -0
- data/lib/redis/recurring_at_intervals/weekly.rb +3 -1
- data/lib/redis/recurring_at_intervals.rb +3 -3
- data/lib/redis-objects-periodical.rb +3 -0
- data/redis-objects-periodical.gemspec +2 -2
- metadata +33 -12
- data/.circleci/config.yml +0 -212
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ce017da5d08688b7f71334c77d3a1c6a8db2b6029a5be69924b0e62c6948c0b
|
|
4
|
+
data.tar.gz: cf73b3a83c8ac045f9800af654e048d4290a7d3732e2a8de2da71a7e2b354a22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/.github/dependabot.yml
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
version: 2
|
|
2
2
|
updates:
|
|
3
3
|
- package-ecosystem: bundler
|
|
4
|
-
|
|
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:
|
|
13
|
+
interval: weekly
|
|
14
|
+
day: monday
|
|
7
15
|
time: "08:00"
|
|
8
|
-
timezone:
|
|
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
|
+
}
|