Skip to content

Commit 2270c82

Browse files
committed
feat: implement merge workflow
This should allow us to retire `talos-bot`. Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
1 parent deaca06 commit 2270c82

8 files changed

Lines changed: 536 additions & 2 deletions

File tree

.github/workflows/slash-merge.yaml

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
2+
#
3+
# Generated on 2026-03-09T17:33:00Z by kres f613471-dirty.
4+
5+
"on":
6+
issue_comment:
7+
types:
8+
- created
9+
name: Slash Merge
10+
jobs:
11+
slash-merge:
12+
permissions:
13+
contents: write
14+
issues: write
15+
pull-requests: write
16+
runs-on: ubuntu-latest
17+
if: |-
18+
github.event.issue.pull_request != null &&
19+
contains(github.event.comment.body, '/nm')
20+
concurrency:
21+
group: slash-merge-${{ github.event.issue.number }}
22+
cancel-in-progress: false
23+
steps:
24+
- name: Add eyes reaction
25+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
26+
with:
27+
github-token: ${{ secrets.GITHUB_TOKEN }}
28+
script: |-
29+
await github.rest.reactions.createForIssueComment({
30+
owner: context.repo.owner,
31+
repo: context.repo.repo,
32+
comment_id: context.payload.comment.id,
33+
content: 'eyes',
34+
});
35+
- name: Check permissions
36+
id: authz
37+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
38+
with:
39+
github-token: ${{ secrets.GITHUB_TOKEN }}
40+
script: |-
41+
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
42+
owner: context.repo.owner,
43+
repo: context.repo.repo,
44+
username: context.payload.comment.user.login,
45+
});
46+
const allowed = ['write', 'maintain', 'admin'];
47+
const ok = allowed.includes(perm.permission);
48+
core.setOutput('authorized', ok ? 'true' : 'false');
49+
if (!ok) {
50+
core.warning('User ' + context.payload.comment.user.login + " has permission '" + perm.permission + "' — not authorized.");
51+
}
52+
- name: Bail if not authorized
53+
if: steps.authz.outputs.authorized != 'true'
54+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
55+
with:
56+
github-token: ${{ secrets.GITHUB_TOKEN }}
57+
script: |-
58+
await github.rest.issues.createComment({
59+
owner: context.repo.owner,
60+
repo: context.repo.repo,
61+
issue_number: context.payload.issue.number,
62+
body: '⛔ @' + context.payload.comment.user.login + ' — you need **write access** to trigger a merge.',
63+
});
64+
core.setFailed('Unauthorized');
65+
- name: Get PR data
66+
id: pr
67+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
68+
with:
69+
github-token: ${{ secrets.GITHUB_TOKEN }}
70+
script: |-
71+
const { data: pr } = await github.rest.pulls.get({
72+
owner: context.repo.owner,
73+
repo: context.repo.repo,
74+
pull_number: context.payload.issue.number,
75+
});
76+
core.setOutput('state', pr.state);
77+
core.setOutput('mergeable', String(pr.mergeable));
78+
core.setOutput('rebaseable', String(pr.rebaseable));
79+
core.setOutput('behind', pr.mergeable_state);
80+
core.setOutput('sha', pr.head.sha);
81+
core.setOutput('base', pr.base.ref);
82+
core.setOutput('title', pr.title);
83+
core.setOutput('draft', String(pr.draft));
84+
- name: Fail if PR is closed or draft
85+
if: steps.pr.outputs.state != 'open' || steps.pr.outputs.draft == 'true'
86+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
87+
with:
88+
github-token: ${{ secrets.GITHUB_TOKEN }}
89+
script: |-
90+
await github.rest.issues.createComment({
91+
owner: context.repo.owner,
92+
repo: context.repo.repo,
93+
issue_number: context.payload.issue.number,
94+
body: '⛔ PR is closed or still a draft — cannot merge.',
95+
});
96+
core.setFailed('PR not open');
97+
- name: Wait for mergeability to be computed
98+
id: wait
99+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
100+
with:
101+
github-token: ${{ secrets.GITHUB_TOKEN }}
102+
script: |-
103+
let state = '${{ steps.pr.outputs.behind }}';
104+
let sha = '${{ steps.pr.outputs.sha }}';
105+
let attempts = 0;
106+
while (state === 'unknown' && attempts < 6) {
107+
await new Promise(r => setTimeout(r, 5000));
108+
const { data: pr } = await github.rest.pulls.get({
109+
owner: context.repo.owner,
110+
repo: context.repo.repo,
111+
pull_number: context.payload.issue.number,
112+
});
113+
state = pr.mergeable_state;
114+
sha = pr.head.sha;
115+
attempts++;
116+
}
117+
core.setOutput('mergeable_state', state);
118+
core.setOutput('sha', sha);
119+
core.info('Final mergeable_state: ' + state);
120+
- name: Fail if PR is behind or has conflicts
121+
if: steps.wait.outputs.mergeable_state == 'behind' || steps.wait.outputs.mergeable_state == 'dirty'
122+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
123+
with:
124+
github-token: ${{ secrets.GITHUB_TOKEN }}
125+
script: |-
126+
const state = '${{ steps.wait.outputs.mergeable_state }}';
127+
const msg = state === 'behind'
128+
? '⛔ The PR is **behind** the base branch. Please rebase or merge `${{ steps.pr.outputs.base }}` first.'
129+
: '⛔ The PR has **merge conflicts**. Please resolve them first.';
130+
await github.rest.issues.createComment({
131+
owner: context.repo.owner,
132+
repo: context.repo.repo,
133+
issue_number: context.payload.issue.number,
134+
body: msg,
135+
});
136+
core.setFailed(state);
137+
- name: Check commit status & check runs
138+
id: checks
139+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
140+
with:
141+
github-token: ${{ secrets.GITHUB_TOKEN }}
142+
script: |-
143+
const sha = '${{ steps.wait.outputs.sha }}';
144+
const { data: status } = await github.rest.repos.getCombinedStatusForRef({
145+
owner: context.repo.owner,
146+
repo: context.repo.repo,
147+
ref: sha,
148+
});
149+
const { data: runs } = await github.rest.checks.listForRef({
150+
owner: context.repo.owner,
151+
repo: context.repo.repo,
152+
ref: sha,
153+
per_page: 100,
154+
});
155+
const otherRuns = runs.check_runs.filter(r => r.name !== context.workflow);
156+
const pending = otherRuns.filter(r => r.status !== 'completed');
157+
const failed = otherRuns.filter(r =>
158+
r.status === 'completed' &&
159+
!['success', 'neutral', 'skipped'].includes(r.conclusion)
160+
);
161+
core.info('Combined status: ' + status.state);
162+
core.info('Pending runs: ' + (pending.map(r => r.name).join(', ') || 'none'));
163+
core.info('Failed runs: ' + (failed.map(r => r.name).join(', ') || 'none'));
164+
if (status.state === 'failure' || status.state === 'error') {
165+
core.setOutput('ok', 'false');
166+
core.setOutput('reason', 'Combined commit status is **' + status.state + '**');
167+
} else if (pending.length > 0) {
168+
core.setOutput('ok', 'false');
169+
core.setOutput('reason', 'Checks still pending: ' + pending.map(r => r.name).join(', '));
170+
} else if (failed.length > 0) {
171+
core.setOutput('ok', 'false');
172+
core.setOutput('reason', 'Checks failed: ' + failed.map(r => r.name).join(', '));
173+
} else {
174+
core.setOutput('ok', 'true');
175+
}
176+
- name: Fail if checks not green
177+
if: steps.checks.outputs.ok != 'true'
178+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
179+
with:
180+
github-token: ${{ secrets.GITHUB_TOKEN }}
181+
script: |-
182+
await github.rest.issues.createComment({
183+
owner: context.repo.owner,
184+
repo: context.repo.repo,
185+
issue_number: context.payload.issue.number,
186+
body: '⛔ Cannot merge — ${{ steps.checks.outputs.reason }}.',
187+
});
188+
core.setFailed('Checks not passing');
189+
- name: Fail if fast-forward was not possible
190+
if: failure() && steps.merge.outputs.ff_failed == 'true'
191+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
192+
with:
193+
github-token: ${{ secrets.GITHUB_TOKEN }}
194+
script: |-
195+
await github.rest.issues.createComment({
196+
owner: context.repo.owner,
197+
repo: context.repo.repo,
198+
issue_number: context.payload.issue.number,
199+
body: '⛔ Fast-forward merge is not possible — the PR branch has diverged from `${{ steps.pr.outputs.base }}`. Please rebase your branch on top of the latest `${{ steps.pr.outputs.base }}` and try again.',
200+
});
201+
core.setFailed('Not fast-forwardable');
202+
- name: Checkout base branch
203+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # version: v6.0.2
204+
with:
205+
fetch-depth: "0"
206+
ref: ${{ steps.pr.outputs.base }}
207+
token: ${{ secrets.MERGE_TOKEN }}
208+
- name: Fast-forward merge
209+
id: merge
210+
env:
211+
BASE: ${{ steps.pr.outputs.base }}
212+
GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
213+
GIT_AUTHOR_NAME: github-actions[bot]
214+
GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
215+
GIT_COMMITTER_NAME: github-actions[bot]
216+
PR_SHA: ${{ steps.wait.outputs.sha }}
217+
run: |
218+
# Ensure we have the latest PR head commit
219+
git fetch origin "$PR_SHA"
220+
221+
# Attempt fast-forward only — exits non-zero if not possible
222+
if ! git merge --ff-only "$PR_SHA"; then
223+
echo "ff_failed=true" >> "$GITHUB_OUTPUT"
224+
exit 1
225+
fi
226+
227+
MERGED_SHA=$(git rev-parse HEAD)
228+
echo "sha=$MERGED_SHA" >> "$GITHUB_OUTPUT"
229+
230+
git push origin "HEAD:$BASE"
231+
- name: Post success comment
232+
if: success()
233+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
234+
with:
235+
github-token: ${{ secrets.GITHUB_TOKEN }}
236+
script: |-
237+
await github.rest.reactions.createForIssueComment({
238+
owner: context.repo.owner,
239+
repo: context.repo.repo,
240+
comment_id: context.payload.comment.id,
241+
content: 'rocket',
242+
});
243+
- name: Post failure comment
244+
if: failure()
245+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # version: v8.0.0
246+
with:
247+
github-token: ${{ secrets.GITHUB_TOKEN }}
248+
script: |-
249+
await github.rest.reactions.createForIssueComment({
250+
owner: context.repo.owner,
251+
repo: context.repo.repo,
252+
comment_id: context.payload.comment.id,
253+
content: '-1',
254+
});

.kres.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,7 @@ spec:
9999
template:
100100
valuesFiles:
101101
- test/test-helm-chart/ci-values.yaml
102+
---
103+
kind: common.Repository
104+
spec:
105+
slashMerge: true

cmd/kres/cmd/gen.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ func runGen() error {
103103
options.MainBranch,
104104
!options.CompileGithubWorkflowsOnly,
105105
!options.SkipStaleWorkflow,
106+
options.SlashMerge,
106107
options.CIFailureSlackNotifyChannel,
107108
)))
108109
}

0 commit comments

Comments
 (0)