This is true, but you get a good separation of concerns by having your CI/CD server be the thing that strictly decides what SHA/tag/branch triggers the job, and your job is written to be flexible enough to build/deploy an arbitrary commit as instructed by the build server or the developer.
For example, I've built Gitlab pipelines with a special job for tags to do the release/deploy, and a manually-triggered job that only runs on master to `bumpversion minor; git push tag $NEWVERSION` for minting the release candidate commit -- this way you push a "bump minor version" button in your CI server, and that job by construction only runs on master, is always the latest master, must be a clean repo, etc. With this pattern you're still free to manually run the job if you need to do a patch release in a hurry, but the happy path is constrained.
(Also, I recommend to run your build/deploy scripts inside a container, that way your local build env is by construction the same as your build server's. Then you don't have the "different build output when I run from a different flavor of Linux/UNIX" problem.)
For example, I've built Gitlab pipelines with a special job for tags to do the release/deploy, and a manually-triggered job that only runs on master to `bumpversion minor; git push tag $NEWVERSION` for minting the release candidate commit -- this way you push a "bump minor version" button in your CI server, and that job by construction only runs on master, is always the latest master, must be a clean repo, etc. With this pattern you're still free to manually run the job if you need to do a patch release in a hurry, but the happy path is constrained.
(Also, I recommend to run your build/deploy scripts inside a container, that way your local build env is by construction the same as your build server's. Then you don't have the "different build output when I run from a different flavor of Linux/UNIX" problem.)