Repository navigation
[raycicmd] add keep-only label rules for step selection - #510
Conversation
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>
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
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
}There was a problem hiding this comment.
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.
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
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)
}
}There was a problem hiding this comment.
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>
|
…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>
Why
rayci selects steps by tag and passes
if:through to Buildkite without evaluating it. A step gated on a PR label, such asif: '!(build.pull_request.labels includes "docstring-only")', is dropped by Buildkite after upload, but rayci has already marked itsdepends_onchain, so its images still build. Gating also has to be written on every step: extending Ray'sdocstring-onlylabel past Ray Data would mean a negatedif: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.yamlfile in a Buildkite directory declares rules keyed by PR label, discovered the way*.rules.txtfiles are: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'skeep_only_tags. The condition is a newacceptKeepOnlycheck alongside the existing tag and select checks instepFilter.accept.rejectDeps. Images that only dropped steps need are no longer marked. A step that a kept step depends on still runs, with its images.*) too, so a labeled PR that also trips a run-all rule stays narrowed.acceptpath, so a group whose own tag is selected but not kept no longer accepts all its steps.no_tag_means_always.selected tags:line, such aslabel "docstring-only": keep only tags: always lint docstring_examples, so the upload log shows why steps dropped.*.labels.yamlfile, 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.rayciversionbump 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
stepFilterkeep-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-endmakePipelinetest with a discovered rules file.go test ./...,go fmt, pre-commit, andrayci-lint go-filelengthandgo-coveragepass.raycicmdcoverage is 90.4%.ray-project/raymaster at8fdacd39fd4, plus a probe commit that adds the rule above and tagsdoc: docstring exampleswithdocstring_examples:docstring-onlypython/ray/actor.py)python/ray/data/dataset.py)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
*.labels.yamlfile the right place, or should rules live in the built-in config?Follow-up
The stacked PR adds
skip_tags_unless_presentandskip_tags_when_present. After a release, Ray bumps.rayciversion, adds its rules file, and removes the per-step labelif:gates.🤖 Generated with Claude Code