Skip to content

[raycicmd] add keep-only label rules for step selection - #510

Merged
elliot-barn merged 3 commits into
mainfrom
reef-209-keep-only-label-rules
Oct 27, 2026
Merged

elliot-barn merged 3 commits into
mainfrom
reef-209-keep-only-label-rules

Conversation

@dstrodtman

Copy link
Copy Markdown
Contributor

Why

rayci selects steps by tag and passes if: through to Buildkite without evaluating it. A step gated on a PR label, such as if: '!(build.pull_request.labels includes "docstring-only")', is dropped by Buildkite after upload, but rayci has already marked its depends_on chain, so its images still build. Gating also has to be written on every step: extending Ray's docstring-only label past Ray Data would mean a negated if: on nearly every premerge test step, and a new step that forgets one silently runs under the label.

This is the first of two stacked PRs that let PR labels adjust tag selection before dependency marking. This one adds the keep-only form. The second adds skip forms.

What changes

A *.labels.yaml file in a Buildkite directory declares rules keyed by PR label, discovered the way *.rules.txt files are:

- label: docstring-only
  keep_only_tags: [always, lint, docstring_examples]

When a rule's label is in BUILDKITE_PULL_REQUEST_LABELS, a step is selected only if it would be selected anyway and it carries one of the rule's keep_only_tags. The condition is a new acceptKeepOnly check alongside the existing tag and select checks in stepFilter.accept.

  • Dropped steps aren't rejected, so they don't go through rejectDeps. Images that only dropped steps need are no longer marked. A step that a kept step depends on still runs, with its images.
  • It applies under run-all (*) too, so a labeled PR that also trips a run-all rule stays narrowed.
  • It applies to group nodes through the same accept path, so a group whose own tag is selected but not kept no longer accepts all its steps.
  • Untagged steps aren't kept, even with no_tag_means_always.
  • When several keep-only rules apply, a step needs a tag from any of them.
  • Each rule that fires logs a line next to the existing selected tags: line, such as label "docstring-only": keep only tags: always lint docstring_examples, so the upload log shows why steps dropped.
  • With no *.labels.yaml file, nothing changes and the labels variable isn't read.

Why a repo file rather than built-in config

Ray's config, including skip_tags, is compiled into rayci (builtin_configs.go). Rules there would need a rayci release and a .rayciversion bump for every label change. A file in the repo's .buildkite/ keeps label policy next to the tag rules it modifies, under the same CODEOWNERS review. The file name and format are open to change.

Testing

  • Unit tests for parsing and validation, label parsing, rule application, stepFilter keep-only including run-all and untagged steps, convertGroups (dropped-only images drop, shared images stay, a kept step's non-kept dependency runs), and an end-to-end makePipeline test with a discovered rules file.
  • go test ./..., go fmt, pre-commit, and rayci-lint go-filelength and go-coverage pass. raycicmd coverage is 90.4%.
  • Simulated Ray premerge with a Linux build of this branch against ray-project/ray master at 8fdacd39fd4, plus a probe commit that adds the rule above and tags doc: docstring examples with docstring_examples:
Simulated PR Unlabeled docstring-only
Core docstring (python/ray/actor.py) 165 steps 13 steps, 5 image jobs
Ray Data docstring (python/ray/data/dataset.py) 51 steps 13 steps, 5 image jobs

Unlabeled output is identical to rayci v0.50.0. The 13 labeled steps are test-rules, the lint steps, the docstring-only scope guard, doc: docstring examples, and their images. Which further steps to keep, such as the API doc consistency check, is a Ray-side choice made when Ray adopts the rule.

Questions for review

  • Is a repo-owned *.labels.yaml file the right place, or should rules live in the built-in config?
  • Should keep-only apply under run-all? This PR says yes.

Follow-up

The stacked PR adds skip_tags_unless_present and skip_tags_when_present. After a release, Ray bumps .rayciversion, adds its rules file, and removes the per-step label if: gates.

🤖 Generated with Claude Code

A `*.labels.yaml` file in a Buildkite directory declares rules keyed by
pull request label. When a rule's label is present in
BUILDKITE_PULL_REQUEST_LABELS, a step is selected only if it would be
selected anyway and it also carries one of the rule's keep_only_tags.

Steps the rule drops are not rejected, so they don't go through
rejectDeps, and images that only those steps need are no longer marked
as dependencies. A step that a kept step depends on still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Douglas Strodtman <douglas@anyscale.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces label-based step filtering rules to adjust pipeline step selection when a pull request carries specific labels. It adds support for parsing label rules from YAML files, loading them, and applying them to the step filter. The review feedback points out that the YAML parser does not gracefully handle empty files or null elements in the rules list, which could lead to unexpected errors or nil pointer dereferences. It is recommended to add checks for empty content and non-nil rules, along with corresponding test cases to ensure robust parsing.

Comment thread raycicmd/label_rules.go
Comment on lines +26 to +50
func parseLabelRulesFile(file string) ([]*labelRule, error) {
bs, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("read label rules file: %w", err)
}

var rules []*labelRule
dec := yaml.NewDecoder(bytes.NewReader(bs))
dec.KnownFields(true)
if err := dec.Decode(&rules); err != nil {
return nil, fmt.Errorf("unmarshal label rules file %s: %w", file, err)
}

for i, r := range rules {
if r.Label == "" {
return nil, fmt.Errorf("label rule %d in %s: label is empty", i, file)
}
if len(r.KeepOnlyTags) == 0 {
return nil, fmt.Errorf(
"label rule %q in %s: no tags to keep", r.Label, file,
)
}
}
return rules, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The YAML parser does not handle empty files or null elements in the rules list gracefully. An empty file causes yaml.NewDecoder.Decode to return io.EOF, resulting in an unexpected error. Additionally, if the YAML list contains a null element, it is unmarshaled as a nil pointer, which will cause a panic (nil pointer dereference) when checking r.Label. Checking for empty content and verifying that each rule is non-nil prevents these issues.

func parseLabelRulesFile(file string) ([]*labelRule, error) {
	bs, err := os.ReadFile(file)
	if err != nil {
		return nil, fmt.Errorf("read label rules file: %w", err)
	}

	if len(bytes.TrimSpace(bs)) == 0 {
		return nil, nil
	}

	var rules []*labelRule
	dec := yaml.NewDecoder(bytes.NewReader(bs))
	dec.KnownFields(true)
	if err := dec.Decode(&rules); err != nil {
		return nil, fmt.Errorf("unmarshal label rules file %s: %w", file, err)
	}

	for i, r := range rules {
		if r == nil {
			return nil, fmt.Errorf("label rule %d in %s: rule is null", i, file)
		}
		if r.Label == "" {
			return nil, fmt.Errorf("label rule %d in %s: label is empty", i, file)
		}
		if len(r.KeepOnlyTags) == 0 {
			return nil, fmt.Errorf(
				"label rule %q in %s: no tags to keep", r.Label, file,
			)
		}
	}
	return rules, nil
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6a6ca5e, with one change from the suggestion. Trimming whitespace would still fail on a comment-only file, which decodes to io.EOF too, so the parser treats io.EOF from Decode as no rules instead. A null entry now returns label rule N in <file>: rule is null.

Comment on lines +37 to +58
func TestParseLabelRulesFile_errors(t *testing.T) {
for _, test := range []struct {
name string
content string
}{{
name: "empty label",
content: "- keep_only_tags: [always]",
}, {
name: "no tags",
content: "- label: docstring-only",
}, {
name: "unknown field",
content: "- label: docstring-only\n keep_tags: [always]",
}} {
t.Run(test.name, func(t *testing.T) {
p := writeLabelRules(t, t.TempDir(), test.content)
if _, err := parseLabelRulesFile(p); err == nil {
t.Errorf("parseLabelRulesFile(%q) = nil error, want error", test.content)
}
})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure robust handling of empty files and null elements in the YAML configuration, we should add corresponding test cases to the test suite.

func TestParseLabelRulesFile_errors(t *testing.T) {
	for _, test := range []struct {
		name    string
		content string
	}{{
		name:    "empty label",
		content: "- keep_only_tags: [always]",
	}, {
		name:    "no tags",
		content: "- label: docstring-only",
	}, {
		name:    "unknown field",
		content: "- label: docstring-only\n  keep_tags: [always]",
	}, {
		name:    "null element",
		content: "- label: docstring-only\n  keep_only_tags: [always]\n- null",
	}} {
		t.Run(test.name, func(t *testing.T) {
			p := writeLabelRules(t, t.TempDir(), test.content)
			if _, err := parseLabelRulesFile(p); err == nil {
				t.Errorf("parseLabelRulesFile(%q) = nil error, want error", test.content)
			}
		})
	}
}

func TestParseLabelRulesFile_empty(t *testing.T) {
	p := writeLabelRules(t, t.TempDir(), "")
	got, err := parseLabelRulesFile(p)
	if err != nil {
		t.Fatalf("parseLabelRulesFile with empty file: %v", err)
	}
	if got != nil {
		t.Errorf("parseLabelRulesFile with empty file = %v, want nil", got)
	}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 6a6ca5e: a null element case in TestParseLabelRulesFile_errors, and TestParseLabelRulesFile_noRules covering empty, newline-only, and comment-only files.

An empty or comment-only *.labels.yaml file decoded to io.EOF and failed
pipeline generation; it now yields no rules. A null list entry caused a
nil pointer dereference; it now returns an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Douglas Strodtman <douglas@anyscale.com>
Stacked on #510. Review that first; this PR's diff is against its
branch.

## Why

#510 adds keep-only label rules, which narrow selection without
rejecting anything. Some steps instead need to be skipped by label: an
opt-in step that should run only when a label is present, such as Ray's
`doc: docs example tests (opt-in)` gated on `docs-example-test`. Today
that gate is an `if:` that Buildkite evaluates after upload, so an
unlabeled PR still builds the step's images.

## What changes

**Two skip forms for label rules:**

```yaml
- label: docs-example-test
  skip_tags_unless_present: [doc_example_optin]
- label: some-label
  skip_tags_when_present: [some_tag]
```

They add tags to the filter's skip set by the label's state, so they
behave exactly like configured `skip_tags`: a skipped step is rejected,
and so is every step that depends on it. Matching `skip_tags` was
deliberate, so a label-skipped step and a config-skipped step behave the
same. Buildkite's docs say a step whose `if:` is false becomes `broken`,
but they don't say what happens to steps that depend on it, so this may
differ from today's `if:` gates when a step depends on a gated one. No
step in Ray does today.

Label rules apply only to pull request builds. When
`BUILDKITE_PULL_REQUEST` is `false`, rayci doesn't apply them and logs
`not a pull request build: label rules not applied`, so non-PR builds
select steps as they did before label rules existed. This keeps gates
such as Ray's `continuous-build`, which run when the label is present or
on postmerge, expressible as a rule. A label-only gate such as
`docs-example-test` keeps its `if:` to stay off postmerge.

**`convertGroups` rejects before it marks dependencies.** Writing the
cascade test exposed a gap that already affects configured `skip_tags`.
When a selected step depends on a skipped step, `markDeps` ran first and
marked the skipped step's own dependencies through the selected one.
`rejectDeps` then rejected both steps but left those dependencies
marked, so a skipped step's images still built. Now `rejectDeps` runs
first, rejected hits are dropped, and `markDeps` runs from the hits that
remain. Because a step that depends on a rejected step is itself
rejected, the remaining hits can't reach a rejected step, so the only
nodes this change unmarks are ones reachable solely through rejected
steps.

## Testing

- Unit tests for parsing the skip forms and for `convertGroups` under
each form with the label present and absent. The tests cover the image
only the skipped step needs being dropped, a shared image staying, and a
dependent of the skipped step being rejected. Without the reorder, the
dropped-image case fails.
- An end-to-end `makePipeline` test covers a `skip_tags_unless_present`
rule on PR builds with and without the label, and on non-PR builds,
where neither skip nor keep-only rules apply. Without the PR-only check,
the non-PR cases fail.
- `go test ./...`, `go fmt`, pre-commit, and `rayci-lint go-filelength`
and `go-coverage` pass. `raycicmd` coverage is 90.6%.
- Simulated Ray premerge with a Linux build of this branch against
`ray-project/ray` master at `8fdacd39fd4`:
- Without any label rule in effect, the emitted pipelines for a doc
prose PR, a Ray Data PR, and a core PR are identical to rayci v0.50.0,
so the reorder changes no current Ray build.
- With a probe `docs-example-test` rule as above, and the opt-in step
tagged `doc_example_optin`, an unlabeled doc prose PR emits 5 steps
instead of 11. The opt-in step and the 5 image jobs only it needs drop.
With the label, all 11 are emitted.

## Questions for review

- Should a skip-form rejection cascade to dependents, as this PR does to
match `skip_tags`?
- The reorder changes existing `skip_tags` behavior in the case above.
Would you rather land it as its own PR first?

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Douglas Strodtman <douglas@anyscale.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

⚠️ PR Size Warning

This PR exceeds the size limits defined in .buildkite/raycilint.yaml.

  • additions: 624 (max allowed: 500)

Break this into smaller PRs, or apply the skip-prcheck label to bypass.

@elliot-barn
elliot-barn merged commit cf380b7 into main Oct 27, 2026
0 of 2 checks passed
@elliot-barn
elliot-barn deleted the reef-209-keep-only-label-rules branch October 27, 2026 21:16
elliot-barn pushed a commit to ray-project/ray that referenced this pull request Oct 28, 2026
…label rules (#67341)

> [!IMPORTANT]
> Draft until rayci ships label rules
([rayci#510](ray-project/rayci#510),
[rayci#511](ray-project/rayci#511)) and this PR
bumps `.rayciversion` to that release. Don't merge it before then:
today's rayci ignores `*.labels.yaml`, so removing the per-step gates
would stop the `docstring-only` label from skipping Ray Data's suites.

## Why are these changes needed?

The `docstring-only` and `docs-example-test` labels gate steps with
Buildkite `if:` conditions, which Buildkite evaluates after rayci has
selected the steps and marked their dependencies. That causes two
problems:

- **Images build for steps that don't run.** An unlabeled prose PR
builds the 5 images only the opt-in step needs. An unlabeled Python PR
builds `docstringbuild` for `doc: docstring examples`.
- **`docstring-only` covers only Ray Data.** Each skipped step needs its
own negated `if:`. Extending the label means a gate on nearly every
premerge test step, and a new step that forgets one silently runs under
the label.

rayci#510 and #511 let PR labels adjust step selection before dependency
marking, through rules in `.buildkite/*.labels.yaml`. This PR adopts
them.

## What changes

- **Adds `.buildkite/docs.labels.yaml`** with the following rules:
- `docstring-only` keeps only steps tagged `always`, `lint`, `doc_api`,
or `docstring_examples`. That's the same set a labeled Data PR runs
today (`doc/source/ray-contribute/ci.md`): the docstring examples step,
the two API surface checks, lint, and the scope guard, which is tagged
`always`. It now applies to every library, not just Ray Data. Without
the label, it skips `doc: docstring examples`.
  - `docs-example-test` skips the opt-in step without the label.
- **Tags the two steps** in `others.rayci.yml`: `docstring_examples` on
`doc: docstring examples`, and `doc_example_optin` on `doc: docs example
tests (opt-in)`.
- **Removes the 10 negated `docstring-only` gates** from
`data.rayci.yml`.
- **Keeps the positive `if:` gates** on the examples step, the opt-in
step, and the scope guard. They're redundant now, but they show a reader
of the step why it's label-gated.

## Behavior change to review

Under `docstring-only`, examples that need torch or TensorFlow are
reported as skipped by the light examples image, and their library's
`doctest()` target is skipped with the rest of the suites. Today that's
true only for Ray Data. With this PR it's true for every library, so
those examples get no premerge coverage on a labeled PR. `ml: data
integration tests`, which #67237 gates with its own `if:`, is also
covered by the keep-only rule, so that gate becomes redundant.

## Duplicate check

No open PR adopts rayci label rules, which don't exist yet in a release.
Related open PRs: #67237 adds a `docstring-only` gate to `ml: data
integration tests`, and #67204 rewrites the `docstring-only` section of
`ci.md`. The `ci.md` update for this change (the label covering every
library) waits until #67204 lands, to avoid conflicting with it.

## Testing

I generated the premerge pipeline locally with a Linux build of rayci
#510 and #511 against this branch. Each row simulates a PR that changes
one file:

| Simulated PR | rayci v0.50.0 on master | This branch, unlabeled | This
branch, labeled |
|---|---|---|---|
| Doc prose (`doc/source/data/aggregating-data.md`) | 11 steps | 5
steps: the opt-in step and the 5 images only it needs drop | 11 steps
with `docs-example-test` |
| Ray Data docstring (`python/ray/data/dataset.py`) | 51 steps | 49
steps: `doc: docstring examples` and `docstringbuild` drop | 19 steps
with `docstring-only` |
| Core docstring (`python/ray/actor.py`) | 165 steps | 163 steps: the
same 2 drop | 19 steps with `docstring-only` |

The 19 labeled steps are test-rules, the lint steps, the scope guard,
both API surface checks, `doc: docstring examples`, and their images.
Apart from the two dropped steps, unlabeled pipelines are identical to
v0.50.0 on master. `pre-commit run` on the changed files passes. Once a
rayci release exists, the `.rayciversion` bump goes here and premerge
runs with and without each label.

This PR was prepared with AI assistance (Claude Code). It stays in draft
until a human review of every changed line and a premerge run on the
release.

---------

Signed-off-by: Douglas Strodtman <douglas@anyscale.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants