GitLab CI Error Guide: 'may not be used with rules' — Fix only/except Conflict
Fix 'may not be used with rules' in GitLab CI: you mixed rules with only/except in one job. Remove the legacy keys and express all conditions in rules.
- #gitlab
- #ci-cd
- #troubleshooting
- #errors
Fixing errors like this? Get 500 free DevOps AI prompts
500 copy-paste AI prompts for the stack you actually run — one PDF, free.
Overview
GitLab refuses to create a pipeline when a single job combines the modern rules: keyword with the legacy only:/except: keywords. The two systems are mutually exclusive per job, and the parser rejects the whole config:
Found errors in your .gitlab-ci.yml:
jobs:test config key may not be used with `rules`: only
The except variant reads the same way (... may not be used with 'rules': except). No pipeline runs until the conflict is removed — this is a validation failure, not a runtime one.
Symptoms
- The pipeline fails to start with a config error naming a job and the offending legacy key.
- A job has both a
rules:block and anonly:orexcept:block, often after a partial migration. - The error appears right after someone added
rules:to a job that already usedonly:. - An
extends:base template suppliesonly:/except:while the child job addsrules:(or vice versa), colliding after merge. - CI Lint flags the job even though each block is individually valid YAML.
Common Root Causes
- Partial migration — a job was moved to
rules:but its oldonly:/except:lines were left behind. - Inheritance collision — a template referenced via
extends:orinclude:definesonly:/except:, and the job addsrules:; the merged job has both. - Copy-paste from mixed examples — snippets combining old and new syntax pasted into one job.
- Default
only:fromworkflowconfusion — assumingworkflow:rulesand job-levelonly:can coexist per job (they cannot alongside jobrules:). - Anchor reuse — a YAML anchor carrying
only:merged into a job that also setsrules:.
Diagnostic Workflow
Find every job that still uses the legacy keys so you can migrate them together:
# Grep for legacy keys alongside rules to spot collisions
# grep -nE '^\s*(only|except|rules):' .gitlab-ci.yml
Validate the merged configuration (expands extends/include) so inherited only: is visible:
lint:
image: curlimages/curl:latest
script:
- |
curl --silent --header "PRIVATE-TOKEN: $LINT_TOKEN" \
"https://$CI_SERVER_HOST/api/v4/projects/$CI_PROJECT_ID/ci/lint" \
--data-urlencode "content=$(cat .gitlab-ci.yml)" \
--data "include_merged_yaml=true"
Then rewrite the job so ALL conditions live in rules: and no only:/except: remains:
# BEFORE — illegal: rules + only in one job
test:
only: [merge_requests]
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script: ./test.sh
# AFTER — everything expressed in rules
test:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
script: ./test.sh
Confirm with CI Lint that the job now parses before pushing.
Example Root Cause Analysis
A project used a shared template for test jobs:
# ci/templates.yml
.test-base:
only:
- merge_requests
- main
A developer added path filtering to one job using the modern syntax:
include:
- local: ci/templates.yml
unit-test:
extends: .test-base # brings in `only:`
rules: # adds `rules:` — now the merged job has BOTH
- changes: ["src/**/*"]
script: ./unit.sh
After merge, unit-test carried both only: (from the base) and rules: (from the child), producing jobs:unit-test config key may not be used with 'rules': only. The fix was to migrate the base template fully to rules: so nothing supplied only: anymore:
# ci/templates.yml
.test-base:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
unit-test:
extends: .test-base
rules: # child rules OVERRIDE base rules entirely
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes: ["src/**/*"]
script: ./unit.sh
Because child rules: fully replace inherited rules:, the team re-stated the branch condition in the child. The pipeline then parsed and ran with the intended path filter.
Prevention Best Practices
- Migrate whole templates — never a single job — from
only:/except:torules:, so inheritance can’t recombine them. - Standardize on
rules:project-wide;only:/except:are legacy and should not appear in new jobs. - Run CI Lint with merged YAML in a pre-merge check to catch inherited-key collisions before the default branch.
- Remember child
rules:fully override inheritedrules:— re-state every condition the child still needs. - Keep condition logic in
workflow:rules(pipeline-level) and jobrules:(job-level); don’t reintroduce jobonly:. - Grep for
only:/except:periodically and treat any remaining usage as tech debt to convert.
Quick Command Reference
# Find legacy keys and rules collisions
grep -nE '^\s*(only|except|rules):' .gitlab-ci.yml ci/*.yml
# Validate merged config (shows inherited only/except)
glab ci lint .gitlab-ci.yml
# CI Lint API with include expansion
curl --header "PRIVATE-TOKEN: $TOKEN" \
"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/ci/lint" \
--data-urlencode "content=$(cat .gitlab-ci.yml)" \
--data "include_merged_yaml=true"
Conclusion
may not be used with rules: only (or except) means one job ended up with both the modern rules: and a legacy only:/except:, usually because a migration was partial or a template supplied the legacy key through extends:. The reliable fix is to move all condition logic into rules: at the template level, remembering that child rules: fully override inherited ones. Guard it with a merged-YAML CI Lint check so inheritance never quietly recombines the two systems again.
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.