We adopted a Gitflow-like process where we have a branch for each stage we deploy to:
development (dev),
test (test),
acceptance (acc)
master (production).
Feature and bugfix branches are started from and merged back to development. These merges are squashed to simplify the Git history (optional). Then, PRs are made from development to test and from test to acceptance to promote these new releases. Each branch triggers its own build and release. This setup allows us to still hotfix specific environments in case of an urgent problem. When merging from development to another environment, we use a normal merge commit to preserve the Git history. This way, new commits just get added to different branches. In order to make sure teammates don't make mistakes when merging, the specific type of merge we want for the branches is specified in the branch protection policies. We do not use release branches or tag specific releases. Instead, we use conventional commit messages and a tool called GitVersion to automatically calculate semantic version numbers that we then use as build and release numbers.