fix: apply signed-merge checks by merge style (#11403)

Fixes #6438

When a protected branch requires signed commits and no signing key is available, fast-forward-only merges should still be allowed because they do not create a new commit.

This patch applies signing checks by merge behaviour/style instead of one global gate:

- pass `mergeStyle` through `CheckPullMergeable(...)` in web/API/automerge paths
- require signing for commit-creating styles (`merge`, `rebase`, `rebase-merge`, `squash`)
- bypass signing precheck only for `fast-forward-only`
- align merge UI options with backend behaviour so signing-dependent styles are unavailable when signing cannot happen
- add Go unit tests for merge-style signing requirements
- add frontend unit coverage for the no-allowed-merge-styles guard

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11403
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: abdo <dev@abdo.wtf>
Co-committed-by: abdo <dev@abdo.wtf>
This commit is contained in:
abdo 2026-04-09 20:26:27 +02:00 committed by Gusted
commit e16dc2ebfd
8 changed files with 129 additions and 25 deletions

View file

@ -32,3 +32,35 @@ test('renders escaped branch name', async () => {
mergeform = await renderMergeForm('<script class="evil">alert("evil message");</script>');
expect(mergeform.get('label[for="delete-branch-after-merge"]').text()).toBe('Delete branch "<script class="evil">alert("evil message");</script>"');
});
test('hides merge controls when no merge style is allowed', () => {
window.config.pageData.pullRequestMergeForm = {
textDeleteBranch: 'Delete branch',
textAutoMergeButtonWhenSucceed: 'when checks succeed',
textAutoMergeWhenSucceed: 'Auto merge when checks succeed',
textAutoMergeCancelSchedule: 'Cancel schedule',
textCancel: 'Cancel',
defaultDeleteBranchAfterMerge: false,
defaultMergeMessage: '',
defaultMergeStyle: 'merge',
emptyCommit: false,
hasPendingPullRequestMerge: false,
hasPendingPullRequestMergeTip: '',
isPullBranchDeletable: false,
canMergeNow: true,
allOverridableChecksOk: true,
pullHeadCommitID: 'abc123',
mergeStyles: [{
name: 'merge',
allowed: false,
textDoMerge: 'Merge',
mergeTitleFieldText: '',
mergeMessageFieldText: '',
hideAutoMerge: false,
}],
};
const mergeform = mount(PullRequestMergeForm);
expect(mergeform.find('.merge-button').exists()).toBe(false);
expect(mergeform.find('form.form-fetch-action').exists()).toBe(false);
});

View file

@ -38,7 +38,8 @@ export default {
},
watch: {
mergeStyle(val) {
this.mergeStyleDetail = this.mergeForm.mergeStyles.find((e) => e.name === val);
const mergeStyleDetail = this.mergeForm.mergeStyles.find((e) => e.name === val);
if (mergeStyleDetail) this.mergeStyleDetail = mergeStyleDetail;
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
}
@ -49,6 +50,7 @@ export default {
let mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed && e.name === this.mergeForm.defaultMergeStyle)?.name;
if (!mergeStyle) mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed)?.name;
if (!mergeStyle) return;
this.switchMergeStyle(mergeStyle, !this.mergeForm.canMergeNow);
},
mounted() {
@ -62,6 +64,7 @@ export default {
this.showMergeStyleMenu = false;
},
toggleActionForm(show) {
if (show && this.mergeStyleAllowedCount === 0) return;
this.showActionForm = show;
if (!show) return;
this.deleteBranchAfterMerge = this.mergeForm.defaultDeleteBranchAfterMerge;
@ -134,7 +137,7 @@ export default {
</div>
</form>
<div v-if="!showActionForm" class="tw-flex">
<div v-if="!showActionForm && mergeStyleAllowedCount > 0" class="tw-flex">
<!-- the merge button -->
<div class="ui buttons merge-button" :class="[mergeForm.emptyCommit ? 'grey' : mergeForm.allOverridableChecksOk ? 'primary' : 'red']" @click="toggleActionForm(true)">
<button class="ui button">
@ -146,7 +149,7 @@ export default {
</template>
</span>
</button>
<div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu" v-if="mergeStyleAllowedCount>1">
<div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu" v-if="mergeStyleAllowedCount > 1">
<svg-icon name="octicon-triangle-down" :size="14"/>
<div class="menu" :class="{'show':showMergeStyleMenu}">
<template v-for="msd in mergeForm.mergeStyles">