We use GitLab CI for internal code and GitHub Actions for public code, and are generally happy with both.
GitHub Actions emphasizes reusability and composability around “actions” as installable packages suitable for a “marketplace” model. This works well, but once you deviate from a standard action, changing its behavior requires learning (or re-learning every six months) the GitHub Actions DSL to make it do what you want. This can be frustrating when it feels like you need to model your task to fit within an abstraction that didn’t help you in the first place.
GitLab CI feels much more “raw” and closer to the metal – there is less emphasis on packaging code into reusable workflows, and more emphasis on the practicalities of operating robust CI pipelines. Yes, you could have a different runner for every task, and “package” CI code into Docker images similarly to how GitHub Actions packages it into workflows. But the service isn’t designed around that idea. And for internal pipelines, that feels like a needless abstraction. It’s nice to keep pipelines simple and robust.
Put another way: there is much less “magic” in GitLab CI than in GitHub Actions. In many ways GitHub Actions feels more like a toy than a tool. This is generally a good thing, IMO, and overall I’d say the tradeoffs are worth it for both services, especially given the different contexts (internal vs. public code) in which we use them.