From ee7de823531e1d458949dcacdd9bca378835a03e Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Tue, 27 Oct 2026 00:06:14 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(automerge):=20approve=20he?= =?UTF-8?q?ld=20workflow=20runs=20for=20registry-trusted=20contributors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contributor lane now approves action_required pull_request runs once per head when the registry path admitted the author at a require_tiers tier and the lane stops at required-check-missing:. Claim-marker, auto-promoted and association admissions, sentinel/exclude labels and .github/workflows/** diffs are never approved for. Toggle: auto_merge.contributor_lane.approve_held_runs_for_registry_trusted (default on). One audit comment per head; structured log line 'contributor-lane approved held runs'. Fixes #14519 Signed-off-by: Andy Anderson --- ...dded-contributor-lane-approve-held-runs.md | 1 + src/cmd/hive/merge_eligibility.go | 21 +- src/docs/github-app-setup.md | 4 +- src/docs/hive-merge.md | 19 ++ src/docs/operator-reference.md | 8 +- src/pkg/config/automerge_trusted_bot_test.go | 15 ++ src/pkg/config/config.go | 16 ++ src/pkg/dashboard/api_config_automerge.go | 42 +-- src/pkg/dashboard/api_config_sections_test.go | 22 +- .../automerge_contributor_held_runs.go | 219 ++++++++++++++++ .../automerge_contributor_held_runs_test.go | 248 ++++++++++++++++++ .../automerge/automerge_contributor_lane.go | 8 + .../automerge_contributor_lane_test.go | 44 +++- .../github/automerge/automerge_held_runs.go | 10 +- 14 files changed, 634 insertions(+), 43 deletions(-) create mode 100644 changelog.d/added-contributor-lane-approve-held-runs.md create mode 100644 src/pkg/github/automerge/automerge_contributor_held_runs.go create mode 100644 src/pkg/github/automerge/automerge_contributor_held_runs_test.go diff --git a/changelog.d/added-contributor-lane-approve-held-runs.md b/changelog.d/added-contributor-lane-approve-held-runs.md new file mode 100644 index 0000000000..7d52332b0b --- /dev/null +++ b/changelog.d/added-contributor-lane-approve-held-runs.md @@ -0,0 +1 @@ +- Contributor lane approves `action_required` workflow runs once per head for a PR author the contributor registry admitted at a `require_tiers` tier when the lane stops at `required-check-missing:` (fork heads included), posting one `hive:contributor-held-runs-approved` audit comment; toggle `auto_merge.contributor_lane.approve_held_runs_for_registry_trusted` (default on). Claim-marker, auto-promoted and association admissions, sentinel/exclude-labelled PRs and `.github/workflows/**` diffs are never approved for (#14519). diff --git a/src/cmd/hive/merge_eligibility.go b/src/cmd/hive/merge_eligibility.go index 4add12684f..9a1289da40 100644 --- a/src/cmd/hive/merge_eligibility.go +++ b/src/cmd/hive/merge_eligibility.go @@ -80,16 +80,17 @@ func contributorLanePolicyFunc(cfg *config.Config) func() automerge.ContributorL p := cfg.AutoMerge.ContributorLane tier := cfg.AutoMerge.ContributorTier return automerge.ContributorLanePolicy{ - Enabled: p.IsEnabled(), - HiveID: cfg.HiveID, - Soak: time.Duration(p.EffectiveSoakMinutes()) * time.Minute, - RequireReviewerVerdict: p.EffectiveRequireReviewerVerdict(), - Repos: p.RepoSet(), - ExcludeLabels: p.ExcludeLabelSet(), - RequireTiers: p.RequireTierSet(), - RegistryTiersSufficient: p.EffectiveRegistryTiersSufficient(), - AutoPromoteAfterMergedPR: tier.EffectiveAutoPromoteAfterMerged(), - AutoPromotionScope: p.EffectiveAutoPromotionScope(), + Enabled: p.IsEnabled(), + HiveID: cfg.HiveID, + Soak: time.Duration(p.EffectiveSoakMinutes()) * time.Minute, + RequireReviewerVerdict: p.EffectiveRequireReviewerVerdict(), + Repos: p.RepoSet(), + ExcludeLabels: p.ExcludeLabelSet(), + RequireTiers: p.RequireTierSet(), + RegistryTiersSufficient: p.EffectiveRegistryTiersSufficient(), + AutoPromoteAfterMergedPR: tier.EffectiveAutoPromoteAfterMerged(), + AutoPromotionScope: p.EffectiveAutoPromotionScope(), + ApproveHeldRunsForRegistryTrusted: p.EffectiveApproveHeldRunsForRegistryTrusted(), } } } diff --git a/src/docs/github-app-setup.md b/src/docs/github-app-setup.md index 4cb2541465..01d54a4a48 100644 --- a/src/docs/github-app-setup.md +++ b/src/docs/github-app-setup.md @@ -43,7 +43,7 @@ Fine-grained PATs are supported — the token is sent as a plain bearer, same as | Actions | Read | Read | Read | | Commit statuses | Read | Read | Read and write — needed when `governor.labels.automerge` is the default `lgtm` on a Prow repository, so the queue action can set the `prow/lgtm` status beside the label. With only Read, every queue action still applies the label but posts a PR comment that the status could not be set. | -Hive does not request **Actions: Read and write** for workflow-run approvals. It only needs **Actions: Read** to inspect runs. When GitHub leaves same-repository `pull_request` runs in `action_required` on trusted Dependabot/Renovate/App PRs or registry-trusted `author/clanker` PRs, Hive posts `/ok-to-test` once for that head and reports `workflow-approval-required:` / `stage/085-needs-decision` until the repository's own self-contained `hive-ok-to-test` workflow approves the held runs. The approval is performed by that repository's `GITHUB_TOKEN` through `.github/workflows/hive-ok-to-test.yml`; no Prow and no Hive App Actions-write grant are required. Hive never approves fork heads or PRs carrying `sentinel-alert`. +Hive does not require **Actions: Read and write** for bot workflow-run approvals; **Actions: Read** is enough to inspect runs. The one direct approval Hive performs — a contributor-lane PR whose author the contributor registry admitted at a `require_tiers` tier (`auto_merge.contributor_lane.approve_held_runs_for_registry_trusted`, default on) — does need Read and write; without it that call fails, is logged once per head, and the PR waits for a person. When GitHub leaves same-repository `pull_request` runs in `action_required` on trusted Dependabot/Renovate/App PRs or registry-trusted `author/clanker` PRs, Hive posts `/ok-to-test` once for that head and reports `workflow-approval-required:` / `stage/085-needs-decision` until the repository's own self-contained `hive-ok-to-test` workflow approves the held runs. The approval is performed by that repository's `GITHUB_TOKEN` through `.github/workflows/hive-ok-to-test.yml`; no Prow and no Hive App Actions-write grant are required. Hive never approves fork heads or PRs carrying `sentinel-alert`. Install the workflow in each governed repository: run `hivectl ok-to-test-template > .github/workflows/hive-ok-to-test.yml` against your hive (it reads the App bot login from `/api/config`; pass `--app-login [bot]` to override), or copy `src/docs/templates/hive-ok-to-test.yml` and replace `HIVE_APP_LOGIN[bot]` with the App bot login shown by Hive for that installation. @@ -90,7 +90,7 @@ Repository permissions used by the dashboard setup UI are: | Issues | Read/write | Enumerate, comment on, create, and close issues. | | Pull requests | Read/write | Create, update, approve/merge, and inspect PRs. | | Checks | Read-only | Monitor CI status. | -| Actions | Read-only | Inspect workflow runs. If GitHub holds trusted Dependabot/App/clanker runs in `action_required`, Hive posts `/ok-to-test`; the repository-local `hive-ok-to-test` workflow uses its own `GITHUB_TOKEN` with Actions write to approve those runs. | +| Actions | Read-only (Read and write to approve registry-trusted contributors' held runs) | Inspect workflow runs. If GitHub holds trusted Dependabot/App/clanker runs in `action_required`, Hive posts `/ok-to-test`; the repository-local `hive-ok-to-test` workflow uses its own `GITHUB_TOKEN` with Actions write to approve those runs. With Read and write, the contributor lane also approves held runs directly for a PR author the contributor registry admitted at a `require_tiers` tier (`auto_merge.contributor_lane.approve_held_runs_for_registry_trusted`). | | Administration | Read-only | Read branch-protection rules before the merge-request watcher lands a PR; Hive refuses closed if it cannot verify the base branch is protected. | | Commit statuses | Read/write | Needed when `governor.labels.automerge` is the default `lgtm` on a repository running Prow's commit-bound `lgtm` (prow-github-actions v3): the queue action sets a `prow/lgtm` success status on the approved head so Prow does not strip the label. Without the write grant the label and approval still land, but every queue action takes the failure branch and posts a PR comment saying the status could not be set. Choose a distinct label such as `hive/auto-merge` if you would rather keep this at Read. | diff --git a/src/docs/hive-merge.md b/src/docs/hive-merge.md index 1f333daaeb..af4d382946 100644 --- a/src/docs/hive-merge.md +++ b/src/docs/hive-merge.md @@ -397,6 +397,24 @@ the issue had been claimed outside the relay; the registry path admits both. Every other gate below still applies to a registry-admitted PR. Set `registry_tiers_sufficient: false` to require the marker for everyone. +A registry-admitted author also gets their CI started. GitHub's fork-approval +policy holds a first-time contributor's `pull_request` runs in +`action_required`, and the hive otherwise never approves a human-authored +lane's held runs (the `hive-ok-to-test` workflow approves same-repo heads +only). hivecommons/docs#481 by a `trusted` registry contributor sat 17 hours +at `required-check-missing:Audit production dependencies` until an operator +clicked "Approve and run". When the registry path admitted the author and the +lane stops at `required-check-missing:`, it now approves the head's held +runs once (`POST /repos/{owner}/{repo}/actions/runs/{id}/approve`, fork head +included), posts one `` audit +comment naming the author and tier, and logs `contributor-lane approved held +runs` with the run ids (`approve_held_runs_for_registry_trusted`, default +`true`). Claim-marker, auto-promotion and association admissions are never +approved for, nor is a PR carrying the sentinel or an `exclude_labels` label, +nor one touching `.github/workflows/**`. The App needs **Actions: Read and +write** for the call; without it the approve fails, is logged once per head, +and the PR waits for a person as before. + A PR merges through this tier only when all of the following hold: - The PR author is admitted by one of three paths: @@ -514,6 +532,7 @@ auto_merge: - merger registry_tiers_sufficient: true # a require_tiers tier on the author's registry profile admits without a claim marker auto_promotion_scope: hive # count clean merged PRs across every hive repo (repo = this repository only) + approve_held_runs_for_registry_trusted: true # approve action_required runs once per head for registry-admitted authors contributor_tier: auto_promote_after_merged: 5 # clean merged PRs before the lane trusts an author on its own; negative disables ``` diff --git a/src/docs/operator-reference.md b/src/docs/operator-reference.md index 43c996d071..9f494b9ee1 100644 --- a/src/docs/operator-reference.md +++ b/src/docs/operator-reference.md @@ -400,7 +400,12 @@ runs when a required check is missing or pending: `automerge hold`; later ticks only count the PR in the skip stats instead of emitting `automerge skip` every tick. - fork heads, sentinel-alert PRs, and human-authored lanes are never touched; a - first-time contributor's held run is still a person's decision. + first-time contributor's held run is still a person's decision — with one + exception: a contributor-lane PR whose author the **contributor registry** + admitted at a `require_tiers` tier has its held runs approved once per head + by the App (`auto_merge.contributor_lane.approve_held_runs_for_registry_trusted`, + default `true`; needs Actions: Read and write). The operator already trusts + that author to merge, so holding their CI is pure drag (hivecommons/docs#481). Per-repo `project.repo_policies[].auto_merge: false` is stronger than the hive-wide L6 setting: the merge relay refuses the repo, the self-authored sweep @@ -435,6 +440,7 @@ advanced into the merge path. | `auto_merge.contributor_lane.registry_tiers_sufficient` | `true` | Admit a PR whose author's contributor-registry profile carries a `require_tiers` tier without a linked issue or `hive:claim` marker (logged as `contributor-lane:registry-trusted`). All other gates still apply. `false` requires the claim marker for every contributor. | | `auto_merge.contributor_lane.auto_promotion_scope` | `hive` | Where the clean-merged-PR ratchet (`auto_merge.contributor_tier.auto_promote_after_merged`) counts an author's merges: `hive` counts across every repository the hive manages (one `search/issues` query finds the repos holding the author's merges; the PR's own repo is always walked first), `repo` counts only the PR's repository. Revert and sentinel-alert evidence blocks promotion in either scope. | | `auto_merge.contributor_tier.auto_promote_after_merged` | `5` | Clean merged PRs (no revert, no sentinel label, not undone by a later merged revert) after which the contributor lane treats an author as `trusted` without an operator grant, unless the registry marks them `revoked`. Negative disables the ratchet. | +| `auto_merge.contributor_lane.approve_held_runs_for_registry_trusted` | `true` | When the registry path admitted the PR author and the lane stops at `required-check-missing:`, approve the head's `action_required` workflow runs once (`POST /repos/{owner}/{repo}/actions/runs/{id}/approve`, fork heads included), post one `hive:contributor-held-runs-approved` audit comment and log `contributor-lane approved held runs`. Never acts for claim-marker, auto-promoted or association admissions, sentinel/exclude-labelled PRs, or diffs touching `.github/workflows/**`. Needs the App's **Actions: Read and write** permission. See [hive-merge.md](hive-merge.md#contributor-lane-auto-merge). | | `auto_merge.contributor_lane.repos` | empty = all watched repos | Optional repo allow-list for the contributor-lane tier (bare name or `owner/repo`). | | `auto_merge.contributor_lane.exclude_labels` | `hold`, `do-not-merge`, `needs-human`, `blocked`, `needs-rebase` | Labels that keep a PR out of contributor-lane auto-merge (`do-not-merge/*` always excluded). | diff --git a/src/pkg/config/automerge_trusted_bot_test.go b/src/pkg/config/automerge_trusted_bot_test.go index 03d36cec14..7f29d30bf6 100644 --- a/src/pkg/config/automerge_trusted_bot_test.go +++ b/src/pkg/config/automerge_trusted_bot_test.go @@ -183,6 +183,21 @@ func TestContributorLaneAutoMergeConfigRegistryTiersSufficient(t *testing.T) { } } +// approve_held_runs_for_registry_trusted defaults on so a registry-admitted +// contributor's held workflow runs are approved; an explicit false keeps them +// a person's decision. +func TestContributorLaneAutoMergeConfigApproveHeldRunsForRegistryTrusted(t *testing.T) { + var cfg ContributorLaneAutoMergeConfig + if !cfg.EffectiveApproveHeldRunsForRegistryTrusted() { + t.Fatal("approve_held_runs_for_registry_trusted must default to true") + } + off := false + cfg.ApproveHeldRunsForRegistryTrusted = &off + if cfg.EffectiveApproveHeldRunsForRegistryTrusted() { + t.Fatal("explicit approve_held_runs_for_registry_trusted: false must turn held-run approval off") + } +} + func TestContributorTierAutoMergeConfigAutoPromoteDefault(t *testing.T) { var cfg ContributorTierAutoMergeConfig if got := cfg.EffectiveAutoPromoteAfterMerged(); got != DefaultContributorTierAutoPromoteAfterMerged { diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go index 70bff59fc4..ac6c2ea530 100644 --- a/src/pkg/config/config.go +++ b/src/pkg/config/config.go @@ -7735,6 +7735,16 @@ type ContributorLaneAutoMergeConfig struct { // own repository (the pre-#14517 behaviour). Revert and sentinel-alert // evidence is applied per PR in either scope. AutoPromotionScope string `yaml:"auto_promotion_scope,omitempty" json:"auto_promotion_scope,omitempty"` + // ApproveHeldRunsForRegistryTrusted lets the contributor lane approve + // `pull_request` workflow runs GitHub holds for approval + // (`action_required`) on a PR whose author the registry path admitted at + // a RequireTiers tier, once per head, when the only thing between the PR + // and merge is a required check those held runs would produce. Authors + // admitted by claim marker, auto-promotion or association are never + // approved for. nil/unset means ON. Needs the App's Actions: Read and + // write permission; without it the approve call fails and the lane keeps + // waiting for a person. + ApproveHeldRunsForRegistryTrusted *bool `yaml:"approve_held_runs_for_registry_trusted,omitempty" json:"approve_held_runs_for_registry_trusted,omitempty"` } // Contributor-lane auto-promotion scopes @@ -7823,6 +7833,12 @@ func (c ContributorLaneAutoMergeConfig) EffectiveRegistryTiersSufficient() bool return c.RegistryTiersSufficient == nil || *c.RegistryTiersSufficient } +// EffectiveApproveHeldRunsForRegistryTrusted reports whether the contributor +// lane may approve held workflow runs for registry-admitted authors (default on). +func (c ContributorLaneAutoMergeConfig) EffectiveApproveHeldRunsForRegistryTrusted() bool { + return c.ApproveHeldRunsForRegistryTrusted == nil || *c.ApproveHeldRunsForRegistryTrusted +} + func (c ContributorLaneAutoMergeConfig) RepoSet() map[string]bool { return repoListSet(c.Repos) } diff --git a/src/pkg/dashboard/api_config_automerge.go b/src/pkg/dashboard/api_config_automerge.go index 50aec7d910..ec40e2ae7d 100644 --- a/src/pkg/dashboard/api_config_automerge.go +++ b/src/pkg/dashboard/api_config_automerge.go @@ -161,14 +161,15 @@ func (s *Server) handleAutoMergePut(w http.ResponseWriter, r *http.Request) { ExcludeLabels []string `json:"exclude_labels"` } `json:"trusted_authors"` ContributorLane *struct { - Enabled *bool `json:"enabled"` - SoakMinutes *int `json:"soak_minutes"` - RequireReviewerVerdict *bool `json:"require_reviewer_verdict"` - Repos []string `json:"repos"` - ExcludeLabels []string `json:"exclude_labels"` - RequireTiers []string `json:"require_tiers"` - RegistryTiersSufficient *bool `json:"registry_tiers_sufficient"` - AutoPromotionScope *string `json:"auto_promotion_scope"` + Enabled *bool `json:"enabled"` + SoakMinutes *int `json:"soak_minutes"` + RequireReviewerVerdict *bool `json:"require_reviewer_verdict"` + Repos []string `json:"repos"` + ExcludeLabels []string `json:"exclude_labels"` + RequireTiers []string `json:"require_tiers"` + RegistryTiersSufficient *bool `json:"registry_tiers_sufficient"` + AutoPromotionScope *string `json:"auto_promotion_scope"` + ApproveHeldRunsForRegistryTrusted *bool `json:"approve_held_runs_for_registry_trusted"` } `json:"contributor_lane"` } if err := decodeBody(r, &body); err != nil { @@ -322,6 +323,10 @@ func (s *Server) handleAutoMergePut(w http.ResponseWriter, r *http.Request) { v := *body.ContributorLane.RegistryTiersSufficient cfg.AutoMerge.ContributorLane.RegistryTiersSufficient = &v } + if body.ContributorLane.ApproveHeldRunsForRegistryTrusted != nil { + v := *body.ContributorLane.ApproveHeldRunsForRegistryTrusted + cfg.AutoMerge.ContributorLane.ApproveHeldRunsForRegistryTrusted = &v + } if body.ContributorLane.Repos != nil { cfg.AutoMerge.ContributorLane.Repos = contributorLaneRepos } @@ -410,16 +415,17 @@ func autoMergeSectionResponse(cfg *config.Config) map[string]interface{} { "roles": []string{config.RoleMerger, config.RoleOwner}, }, "contributor_lane": map[string]interface{}{ - "enabled": cl.IsEnabled(), - "soak_minutes": cl.EffectiveSoakMinutes(), - "require_reviewer_verdict": cl.EffectiveRequireReviewerVerdict(), - "repos": contributorLaneRepos, - "exclude_labels": contributorLaneLabels, - "require_tiers": cl.EffectiveRequireTiers(), - "registry_tiers_sufficient": cl.EffectiveRegistryTiersSufficient(), - "auto_promotion_scope": cl.EffectiveAutoPromotionScope(), - "auto_promotion_scopes": config.ContributorLaneAutoPromotionScopes, - "grantable_tiers": config.ContributorLaneGrantableTiers, + "enabled": cl.IsEnabled(), + "soak_minutes": cl.EffectiveSoakMinutes(), + "require_reviewer_verdict": cl.EffectiveRequireReviewerVerdict(), + "repos": contributorLaneRepos, + "exclude_labels": contributorLaneLabels, + "require_tiers": cl.EffectiveRequireTiers(), + "registry_tiers_sufficient": cl.EffectiveRegistryTiersSufficient(), + "auto_promotion_scope": cl.EffectiveAutoPromotionScope(), + "auto_promotion_scopes": config.ContributorLaneAutoPromotionScopes, + "grantable_tiers": config.ContributorLaneGrantableTiers, + "approve_held_runs_for_registry_trusted": cl.EffectiveApproveHeldRunsForRegistryTrusted(), }, } } diff --git a/src/pkg/dashboard/api_config_sections_test.go b/src/pkg/dashboard/api_config_sections_test.go index 7af3a4d98b..046be585fb 100644 --- a/src/pkg/dashboard/api_config_sections_test.go +++ b/src/pkg/dashboard/api_config_sections_test.go @@ -641,6 +641,7 @@ func TestAutoMergeGet_ContributorLaneDefaults(t *testing.T) { RegistryTiers bool `json:"registry_tiers_sufficient"` AutoPromotionScope string `json:"auto_promotion_scope"` AutoPromotionScopes []string `json:"auto_promotion_scopes"` + ApproveHeldRuns bool `json:"approve_held_runs_for_registry_trusted"` GrantableTiers []string `json:"grantable_tiers"` } `json:"contributor_lane"` } @@ -654,7 +655,7 @@ func TestAutoMergeGet_ContributorLaneDefaults(t *testing.T) { if strings.Join(cl.GrantableTiers, ",") != strings.Join(config.ContributorLaneGrantableTiers, ",") { t.Fatalf("contributor_lane grantable_tiers = %v", cl.GrantableTiers) } - if !cl.Enabled || cl.SoakMinutes != config.DefaultContributorLaneSoakMinutes || !cl.RequireReviewerVerdict || !cl.RegistryTiers || len(cl.Repos) != 0 { + if !cl.Enabled || cl.SoakMinutes != config.DefaultContributorLaneSoakMinutes || !cl.RequireReviewerVerdict || !cl.RegistryTiers || !cl.ApproveHeldRuns || len(cl.Repos) != 0 { t.Fatalf("contributor_lane defaults wrong: %+v", cl) } if cl.AutoPromotionScope != config.ContributorLaneAutoPromotionScopeHive || strings.Join(cl.AutoPromotionScopes, ",") != "hive,repo" { @@ -669,21 +670,22 @@ func TestAutoMergePut_ContributorLaneValidation(t *testing.T) { s := covApiServer(t) valid := map[string]any{ "contributor_lane": map[string]any{ - "enabled": false, - "soak_minutes": 30, - "require_reviewer_verdict": false, - "registry_tiers_sufficient": false, - "repos": []string{" widget ", ""}, - "exclude_labels": []string{"hold"}, - "require_tiers": []string{" merger "}, - "auto_promotion_scope": " Repo ", + "enabled": false, + "soak_minutes": 30, + "require_reviewer_verdict": false, + "registry_tiers_sufficient": false, + "repos": []string{" widget ", ""}, + "exclude_labels": []string{"hold"}, + "require_tiers": []string{" merger "}, + "auto_promotion_scope": " Repo ", + "approve_held_runs_for_registry_trusted": false, }, } if rec := doPut(s, "/api/config/auto-merge", valid); rec.Code != http.StatusOK { t.Fatalf("valid contributor_lane: expected 200, got %d: %s", rec.Code, rec.Body.String()) } cl := s.deps.Config.AutoMerge.ContributorLane - if cl.IsEnabled() || cl.EffectiveSoakMinutes() != 30 || cl.EffectiveRequireReviewerVerdict() || cl.EffectiveRegistryTiersSufficient() { + if cl.IsEnabled() || cl.EffectiveSoakMinutes() != 30 || cl.EffectiveRequireReviewerVerdict() || cl.EffectiveRegistryTiersSufficient() || cl.EffectiveApproveHeldRunsForRegistryTrusted() { t.Fatalf("contributor_lane write did not apply: %+v", cl) } if cl.AutoPromotionScope != config.ContributorLaneAutoPromotionScopeRepo { diff --git a/src/pkg/github/automerge/automerge_contributor_held_runs.go b/src/pkg/github/automerge/automerge_contributor_held_runs.go new file mode 100644 index 0000000000..b0ff8e57ad --- /dev/null +++ b/src/pkg/github/automerge/automerge_contributor_held_runs.go @@ -0,0 +1,219 @@ +package automerge + +// Held workflow runs on registry-trusted contributor PRs (#14519). +// +// GitHub's fork-approval policy holds a first-time contributor's +// `pull_request` runs in `action_required`; nothing starts them until a person +// clicks "Approve and run". automerge_held_runs.go deliberately never nudges a +// human-authored lane. The one exception is here: an author the operator +// placed in this hive's contributor registry at a tier the lane accepts for +// MERGING (require_tiers) is trusted to land the change, so holding their CI +// is pure drag (hivecommons/docs#481 sat 17h). The lane approves those runs +// once per head with the App token when, and only when, the registry path +// admitted the author and the lane's own CI reason is +// `required-check-missing:`. Claim-marker, auto-promotion and +// association admissions stay untouched. + +import ( + "context" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + gh "github.com/google/go-github/v72/github" + "github.com/hivecommons/hive/pkg/effects" + hgithub "github.com/hivecommons/hive/pkg/github" + "github.com/hivecommons/hive/pkg/intent" +) + +const ( + // contributorHeldRunsApprovedMarkerPrefix tags the one audit comment the + // lane posts after approving a head's held runs. + contributorHeldRunsApprovedMarkerPrefix = "\nHive approved the workflow runs GitHub was holding for approval on this head (run %s) because @%s is registry-trusted at tier `%s`, which the contributor lane accepts for unattended merge. CI, reviewer verdict, diff fences, soak, holds and branch protection still apply before anything merges.", + contributorHeldRunsApprovedMarkerPrefix, strings.ToLower(headSHA), author, tier, strings.Join(ids, ","), strings.Join(ids, ", "), author, tier) +} + +// commentContributorHeldRunsApproved posts the audit comment once per head: +// an existing marker for this head on the PR, or a prior effects claim, +// suppresses it. +func (c *Engine) commentContributorHeldRunsApproved(ctx context.Context, owner, repo string, number int, headSHA, author, tier string, runIDs []int64) error { + if c == nil || c.gh == nil || number <= 0 { + return nil + } + comments, err := c.listIssueComments(ctx, owner, repo, number) + if err != nil { + return err + } + marker := contributorHeldRunsApprovedMarkerPrefix + " head=" + strings.ToLower(headSHA) + for _, comment := range comments { + if comment != nil && strings.HasPrefix(comment.GetBody(), marker) { + return nil + } + } + body := contributorHeldRunsApprovedComment(headSHA, author, tier, runIDs) + _, err = effects.Execute(ctx, c.mutation, effects.Claim{ + Repo: owner + "/" + repo, + Kind: effects.KindIssueComment, + Target: strconv.Itoa(number), + Actor: "automerge", + Inputs: map[string]string{"head_sha": headSHA, "body": effects.StableDigest(contributorHeldRunsApprovedMarkerPrefix)}, + }, func(ctx context.Context) (effects.Result, error) { + comment, _, apiErr := c.gh.Issues.CreateComment(hgithub.WithRESTCaller(ctx, "hive:contributor_lane_automerge_sweep"), owner, repo, number, &gh.IssueComment{Body: gh.Ptr(body)}) + if apiErr != nil { + return effects.Result{}, effects.NotApplied(apiErr) + } + c.noteOwnStageComment(owner, repo, number) + return effects.Result{Provenance: comment.GetHTMLURL()}, nil + }) + return err +} diff --git a/src/pkg/github/automerge/automerge_contributor_held_runs_test.go b/src/pkg/github/automerge/automerge_contributor_held_runs_test.go new file mode 100644 index 0000000000..130555146f --- /dev/null +++ b/src/pkg/github/automerge/automerge_contributor_held_runs_test.go @@ -0,0 +1,248 @@ +package automerge + +// Held workflow runs on registry-trusted contributor PRs (#14519). These pin +// that the contributor lane approves `action_required` runs once per head +// only for an author the registry path admitted, and never for the other +// admission paths, labelled PRs, CI-definition diffs or with the toggle off. + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + gh "github.com/google/go-github/v72/github" +) + +const contributorHeldRunsTestContext = "Audit production dependencies" + +func TestContributorLaneApprovesHeldRunsForRegistryTrusted(t *testing.T) { + registryTrusted := registryTiers(map[string]string{contributorLaneTestAuthor: "trusted"}) + noMarker := func(fx *contributorLaneFixture) { + fx.body = "Part of #42\n\nSome change." + fx.issueComments = nil + } + cleanHistory := func(n int) []map[string]any { + history := make([]map[string]any, 0, n) + for i := 0; i < n; i++ { + history = append(history, mergedHistoryPR(contributorLaneTestAuthor, "fix: clean change")) + } + return history + } + wantReason := requiredCheckMissingPrefix + contributorHeldRunsTestContext + for _, tc := range []struct { + name string + registry ContributorRegistryTierFunc + mutate func(fx *contributorLaneFixture) + policy func(p *ContributorLanePolicy) + wantReason string + wantApproved []int64 + wantComments int + }{ + { + name: "registry trusted fork head approves once", + registry: registryTrusted, + mutate: func(fx *contributorLaneFixture) { noMarker(fx); fx.headRepo = "thatguywob/widget" }, + wantReason: wantReason, + wantApproved: []int64{11, 12}, + wantComments: 1, + }, + { + name: "registry trusted same-repo head approves once", + registry: registryTrusted, + mutate: noMarker, + wantReason: wantReason, + wantApproved: []int64{11, 12}, + wantComments: 1, + }, + { + name: "auto-promoted author is not approved for", + registry: registryTiers(nil), + mutate: func(fx *contributorLaneFixture) { + fx.issueComments[0]["body"] = contributorClaimMarkerTier(contributorLaneTestAuthor, "contributor", contributorLaneTestHive, " tier=contributor verified=true") + fx.mergedHistory = cleanHistory(3) + }, + policy: func(p *ContributorLanePolicy) { p.AutoPromoteAfterMergedPR = 3 }, + wantReason: wantReason, + wantComments: 1, // the auto-promotion audit comment only + }, + { + name: "claim marker author is not approved for", + registry: registryTiers(nil), + wantReason: wantReason, + }, + { + name: "sentinel label refuses", + registry: registryTrusted, + mutate: func(fx *contributorLaneFixture) { noMarker(fx); fx.labels = []string{"hive-sentinel"} }, + wantReason: contributorLaneReasonSentinelBlocked, + }, + { + name: "exclude label refuses", + registry: registryTrusted, + mutate: func(fx *contributorLaneFixture) { noMarker(fx); fx.labels = []string{"blocked"} }, + wantReason: "excluded-label:blocked", + }, + { + name: "workflow path refuses", + registry: registryTrusted, + mutate: func(fx *contributorLaneFixture) { + noMarker(fx) + fx.files = []map[string]any{{"filename": ".github/workflows/ci.yml", "status": "modified", "additions": 1, "deletions": 1}} + }, + wantReason: contributorLaneReasonWorkflowPath, + }, + { + name: "toggle off does nothing", + registry: registryTrusted, + mutate: noMarker, + policy: func(p *ContributorLanePolicy) { p.ApproveHeldRunsForRegistryTrusted = false }, + wantReason: wantReason, + }, + { + name: "no held runs does nothing", + registry: registryTrusted, + mutate: func(fx *contributorLaneFixture) { noMarker(fx); fx.workflowRuns = []map[string]any{} }, + wantReason: wantReason, + }, + { + name: "approve forbidden logs and posts no comment", + registry: registryTrusted, + mutate: func(fx *contributorLaneFixture) { noMarker(fx); fx.approveStatus = http.StatusForbidden }, + wantReason: wantReason, + wantApproved: []int64{11, 12}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fx := validContributorFixture() + fx.baseRef = "main" + fx.requiredContexts = []string{"ci/build", contributorHeldRunsTestContext} + headRepo := "acme/widget" + if tc.mutate != nil { + tc.mutate(&fx) + } + if fx.headRepo != "" { + headRepo = fx.headRepo + } + if fx.workflowRuns == nil { + fx.workflowRuns = []map[string]any{ + heldRun(11, headRepo), + heldRun(12, headRepo), + {"id": 13, "name": "lint", "status": "completed", "conclusion": "success", "head_repository": map[string]any{"full_name": headRepo}}, + heldRun(14, "mallory/widget"), + } + } + api := newContributorLaneAPI(t, &fx) + defer api.Close() + policy := defaultContributorLanePolicy() + policy.RegistryTiersSufficient = true + policy.ApproveHeldRunsForRegistryTrusted = true + if tc.policy != nil { + tc.policy(&policy) + } + clock := &contributorLaneClock{at: time.Date(2026, 10, 27, 13, 0, 0, 0, time.UTC)} + engine := newContributorLaneEngine(api.URL, policy, clock, Options{ContributorRegistryTier: tc.registry, ContributorSoak: soakedTracker(clock)}) + + for tick := 1; tick <= 2; tick++ { + _, reason, err := engine.trySweepContributorLanePR(context.Background(), "widget", "acme", "widget", 7, policy) + if err != nil { + t.Fatalf("tick %d err = %v", tick, err) + } + if reason != tc.wantReason { + t.Fatalf("tick %d reason = %q, want %q", tick, reason, tc.wantReason) + } + } + if fx.merges != 0 { + t.Fatalf("merges = %d, want 0 while a required check is missing", fx.merges) + } + if got, want := fx.approvedRuns, tc.wantApproved; len(got) != len(want) { + t.Fatalf("approved runs = %v, want %v (once across two ticks)", got, want) + } else { + for i := range want { + if got[i] != want[i] { + t.Fatalf("approved runs = %v, want %v", got, want) + } + } + } + if fx.comments != tc.wantComments { + t.Fatalf("comments = %d, want %d last=%q", fx.comments, tc.wantComments, fx.lastComment) + } + if len(tc.wantApproved) > 0 && tc.wantComments > 0 { + wantMarker := contributorHeldRunsApprovedMarkerPrefix + " head=sha7 author=" + contributorLaneTestAuthor + " tier=trusted runs=11,12 -->" + if !strings.HasPrefix(fx.lastComment, wantMarker) || !strings.Contains(fx.lastComment, "registry-trusted at tier `trusted`") { + t.Fatalf("comment = %q, want marker %q", fx.lastComment, wantMarker) + } + } + }) + } +} + +func TestContributorLaneHeldRunsApprovalCommentIsIdempotent(t *testing.T) { + fx := validContributorFixture() + fx.baseRef = "main" + fx.body = "Part of #42\n\nSome change." + fx.issueComments = nil + fx.requiredContexts = []string{"ci/build", contributorHeldRunsTestContext} + fx.workflowRuns = []map[string]any{heldRun(11, "acme/widget")} + fx.prComments = []map[string]any{{ + "id": 5, + "user": map[string]string{"login": testHiveAppBotLogin}, + "body": contributorHeldRunsApprovedComment("sha7", contributorLaneTestAuthor, "trusted", []int64{11}), + }} + api := newContributorLaneAPI(t, &fx) + defer api.Close() + policy := defaultContributorLanePolicy() + policy.RegistryTiersSufficient = true + policy.ApproveHeldRunsForRegistryTrusted = true + clock := &contributorLaneClock{at: time.Date(2026, 10, 27, 13, 0, 0, 0, time.UTC)} + engine := newContributorLaneEngine(api.URL, policy, clock, Options{ContributorRegistryTier: registryTiers(map[string]string{contributorLaneTestAuthor: "merger"}), ContributorSoak: soakedTracker(clock)}) + + _, reason, err := engine.trySweepContributorLanePR(context.Background(), "widget", "acme", "widget", 7, policy) + if err != nil { + t.Fatal(err) + } + if want := requiredCheckMissingPrefix + contributorHeldRunsTestContext; reason != want { + t.Fatalf("reason = %q, want %q", reason, want) + } + if len(fx.approvedRuns) != 1 || fx.approvedRuns[0] != 11 { + t.Fatalf("approved runs = %v, want [11]", fx.approvedRuns) + } + if fx.comments != 0 { + t.Fatalf("comments = %d, want 0 when the head marker is already on the PR", fx.comments) + } +} + +func TestHeldWorkflowRunsForHeadMatchesForkHead(t *testing.T) { + run := func(id int64, conclusion, headRepo string) *gh.WorkflowRun { + r := &gh.WorkflowRun{ID: gh.Ptr(id), Conclusion: gh.Ptr(conclusion)} + if headRepo != "" { + r.HeadRepository = &gh.Repository{FullName: gh.Ptr(headRepo)} + } + return r + } + runs := []*gh.WorkflowRun{ + run(1, "action_required", "thatguywob/widget"), + run(2, "action_required", "acme/widget"), + run(3, "action_required", ""), + run(4, "success", "thatguywob/widget"), + nil, + } + held := heldWorkflowRunsForHead(runs, "ThatGuyWob/Widget") + if len(held) != 1 || held[0].GetID() != 1 { + t.Fatalf("held = %v, want only run 1", held) + } + if got := heldWorkflowRunsForHead(nil, "acme/widget"); got != nil { + t.Fatalf("held for nil runs = %v, want nil", got) + } +} + +func TestContributorLaneHeadRepoFallsBackToBase(t *testing.T) { + if got := contributorLaneHeadRepo(&gh.PullRequest{}, "acme", "widget"); got != "acme/widget" { + t.Fatalf("head repo = %q, want base fallback", got) + } + pr := &gh.PullRequest{Head: &gh.PullRequestBranch{Repo: &gh.Repository{FullName: gh.Ptr("fork/widget")}}} + if got := contributorLaneHeadRepo(pr, "acme", "widget"); got != "fork/widget" { + t.Fatalf("head repo = %q, want fork", got) + } +} diff --git a/src/pkg/github/automerge/automerge_contributor_lane.go b/src/pkg/github/automerge/automerge_contributor_lane.go index 6a7ecad766..1ba2fcf169 100644 --- a/src/pkg/github/automerge/automerge_contributor_lane.go +++ b/src/pkg/github/automerge/automerge_contributor_lane.go @@ -200,6 +200,11 @@ type ContributorLanePolicy struct { // PR's own repository only; anything else (the default, "hive") counts // every repository this hive manages (#14517). AutoPromotionScope string + // ApproveHeldRunsForRegistryTrusted lets the lane approve `action_required` + // workflow runs once per head for an author the registry path admitted + // (auto_merge.contributor_lane.approve_held_runs_for_registry_trusted). + // See automerge_contributor_held_runs.go. + ApproveHeldRunsForRegistryTrusted bool } // Contributor-lane auto-promotion scopes; mirrors @@ -1248,6 +1253,9 @@ func (c *Engine) evaluateContributorLanePR(ctx context.Context, displayRepo, own return eval, reason, err } if !green { + if strings.HasPrefix(reason, requiredCheckMissingPrefix) { + c.approveHeldRunsForRegistryTrusted(ctx, displayRepo, owner, repo, pr, number, headSHA, claim, policy, files, reason) + } return eval, reason, nil } if !verdict.Configured { diff --git a/src/pkg/github/automerge/automerge_contributor_lane_test.go b/src/pkg/github/automerge/automerge_contributor_lane_test.go index 0fa8739214..2b433a4693 100644 --- a/src/pkg/github/automerge/automerge_contributor_lane_test.go +++ b/src/pkg/github/automerge/automerge_contributor_lane_test.go @@ -11,6 +11,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -93,6 +94,18 @@ type contributorLaneFixture struct { baseRef string behindBy int // behind_by reported by compare/...sha7 compares int + // requiredContexts, when set with baseRef, is what branch protection + // declares as required_status_checks.contexts; empty keeps the branch + // unprotected. The status/check-runs routes only ever report ci/build, + // so any other name yields required-check-missing:. + requiredContexts []string + // workflowRuns is the payload of GET actions/runs?head_sha=sha7; nil + // serves an empty list. + workflowRuns []map[string]any + // approvedRuns records POST actions/runs/{id}/approve calls; + // approveStatus, when set, is the status every approve returns. + approvedRuns []int64 + approveStatus int } func newContributorLaneAPI(t *testing.T, fx *contributorLaneFixture) *httptest.Server { @@ -258,8 +271,30 @@ func newContributorLaneAPI(t *testing.T, fx *contributorLaneFixture) *httptest.S case fx.baseRef != "" && r.Method == http.MethodGet && r.URL.Path == "/repos/acme/widget/branches/"+fx.baseRef: json.NewEncoder(w).Encode(map[string]any{"name": fx.baseRef, "commit": map[string]string{"sha": "base1"}}) case fx.baseRef != "" && r.Method == http.MethodGet && r.URL.Path == "/repos/acme/widget/branches/"+fx.baseRef+"/protection/required_status_checks": + if len(fx.requiredContexts) > 0 { + json.NewEncoder(w).Encode(map[string]any{"strict": false, "contexts": fx.requiredContexts}) + return + } w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]any{"message": "Branch not protected"}) + case r.Method == http.MethodGet && r.URL.Path == "/repos/acme/widget/actions/runs": + runs := fx.workflowRuns + if runs == nil { + runs = []map[string]any{} + } + json.NewEncoder(w).Encode(map[string]any{"total_count": len(runs), "workflow_runs": runs}) + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/repos/acme/widget/actions/runs/") && strings.HasSuffix(r.URL.Path, "/approve"): + id, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/repos/acme/widget/actions/runs/"), "/approve"), 10, 64) + if err != nil { + t.Fatalf("bad approve path %s: %v", r.URL.Path, err) + } + fx.approvedRuns = append(fx.approvedRuns, id) + if fx.approveStatus != 0 { + w.WriteHeader(fx.approveStatus) + json.NewEncoder(w).Encode(map[string]string{"message": "Resource not accessible by integration"}) + return + } + w.WriteHeader(http.StatusCreated) case fx.baseRef != "" && r.Method == http.MethodGet && r.URL.Path == "/repos/acme/widget/rules/branches/"+fx.baseRef: json.NewEncoder(w).Encode([]any{}) case fx.baseRef != "" && r.Method == http.MethodGet && r.URL.Path == "/repos/acme/widget/commits/base1/status": @@ -282,7 +317,14 @@ func newContributorLaneAPI(t *testing.T, fx *contributorLaneFixture) *httptest.S _ = json.NewDecoder(r.Body).Decode(&payload) fx.lastComment = payload.Body fx.allComments = append(fx.allComments, payload.Body) - json.NewEncoder(w).Encode(map[string]any{"id": 1}) + // Posted comments become visible to later GETs so once-per-PR + // markers can be exercised across ticks. + fx.prComments = append(fx.prComments, map[string]any{ + "id": 1000 + fx.comments, + "user": map[string]string{"login": testHiveAppBotLogin}, + "body": payload.Body, + }) + json.NewEncoder(w).Encode(map[string]any{"id": 1000 + fx.comments}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) } diff --git a/src/pkg/github/automerge/automerge_held_runs.go b/src/pkg/github/automerge/automerge_held_runs.go index e6f6b8a089..4772482b1b 100644 --- a/src/pkg/github/automerge/automerge_held_runs.go +++ b/src/pkg/github/automerge/automerge_held_runs.go @@ -28,7 +28,15 @@ package automerge // acts for (trySweepSelfAuthoredPR); human-authored lanes are untouched so a // first-time contributor's held run is never nudged by automation. Hive posts // `/ok-to-test`; the repository-local self-contained workflow approves only same-repo -// heads, never fork heads. +// heads, never fork heads. The one human exception lives in +// automerge_contributor_held_runs.go (#14519): a contributor the operator +// placed in this hive's registry at a tier the contributor lane accepts for +// merging (require_tiers) gets their `action_required` runs approved once per +// head with the App token — fork head included — when the lane itself stops +// at `required-check-missing:`. Claim-marker, auto-promotion and +// association admissions, sentinel/exclude labels and `.github/workflows/**` +// diffs stay a person's decision, and the toggle is +// auto_merge.contributor_lane.approve_held_runs_for_registry_trusted. import ( "context" From f35e65ba07e30b637fcdaf16dbaefde11cb0f58a Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Tue, 27 Oct 2026 00:14:57 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20feat(dashboard):=20surface=20ap?= =?UTF-8?q?prove=5Fheld=5Fruns=5Ffor=5Fregistry=5Ftrusted=20in=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings-parity test requires every hive.yaml leaf to be referenced by the Settings UI; add the contributor-lane toggle next to registry_tiers_sufficient. Signed-off-by: Andy Anderson --- src/pkg/dashboard/static/index.html | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index cf786c412d..1ac61344d6 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -49637,6 +49637,7 @@

${esc(s.title)}

const amContributorLaneOn = amContributorLane.enabled !== false; // nil/undefined = enabled const amContributorLaneVerdict = amContributorLane.require_reviewer_verdict !== false; const amContributorLaneRegistry = amContributorLane.registry_tiers_sufficient !== false; + const amContributorLaneHeldRuns = amContributorLane.approve_held_runs_for_registry_trusted !== false; const amContributorLaneSoak = Number.isFinite(Number(amContributorLane.soak_minutes)) ? Number(amContributorLane.soak_minutes) : 30; const amContributorLaneLabels = Array.isArray(amContributorLane.exclude_labels) ? amContributorLane.exclude_labels : ['hold','do-not-merge','needs-human','blocked','needs-rebase']; const amContributorLaneTiers = Array.isArray(amContributorLane.require_tiers) && amContributorLane.require_tiers.length ? amContributorLane.require_tiers : ['merger','trusted']; @@ -49830,6 +49831,13 @@

${esc(s.title)}

Admit by registry tier ${settingHelpMark('hive-merge', 'contributor-lane-auto-merge', 'Admit by registry tier', `When on (the default), the operator-set trust tier on the author's contributor profile satisfies the tier requirement on its own; every other gate (CI, reviewer verdict, soak, diff fences) still applies.`)} +
+ +
+
+ Start CI held by GitHub's fork-approval policy ${settingHelpMark('hive-merge', 'contributor-lane-auto-merge', 'Start CI held by fork-approval policy', `GitHub holds a first-time contributor's pull_request runs until someone clicks Approve and run. If the operator already trusts the author to merge via the registry, holding their CI is pure drag; the hive approves the head's held runs once and logs contributor-lane approved held runs.`)} +
+
@@ -49845,7 +49853,7 @@

${esc(s.title)}

Leave every repo unselected to allow all watched repositories. Skips are logged as contributor-lane:* reasons (no-claim, no-linked-issue, claim-author-mismatch, claim-other-hive, claim-tier-untrusted, claim-unverified, author-association, workflow-path, tests-removed, no-reviewer, no-verdict, soaking, sentinel-blocked); a registry-admitted PR logs contributor-lane:registry-trusted.

-

YAML keys: auto_merge.contributor_lane.enabled, auto_merge.contributor_lane.soak_minutes, auto_merge.contributor_lane.require_reviewer_verdict, auto_merge.contributor_lane.registry_tiers_sufficient, auto_merge.contributor_lane.auto_promotion_scope, auto_merge.contributor_lane.repos[], auto_merge.contributor_lane.exclude_labels[], auto_merge.contributor_lane.require_tiers[].

+

YAML keys: auto_merge.contributor_lane.enabled, auto_merge.contributor_lane.soak_minutes, auto_merge.contributor_lane.require_reviewer_verdict, auto_merge.contributor_lane.registry_tiers_sufficient, auto_merge.contributor_lane.approve_held_runs_for_registry_trusted, auto_merge.contributor_lane.auto_promotion_scope, auto_merge.contributor_lane.repos[], auto_merge.contributor_lane.exclude_labels[], auto_merge.contributor_lane.require_tiers[].

@@ -51363,6 +51371,7 @@

${esc(s.title)}

'auto_merge.contributor_lane.enabled': { tab: 'Features', stage: v => complianceStageContributorLane('enabled', v) }, 'auto_merge.contributor_lane.require_reviewer_verdict': { tab: 'Features', stage: v => complianceStageContributorLane('require_reviewer_verdict', v) }, 'auto_merge.contributor_lane.registry_tiers_sufficient': { tab: 'Features', stage: v => complianceStageContributorLane('registry_tiers_sufficient', v) }, + 'auto_merge.contributor_lane.approve_held_runs_for_registry_trusted': { tab: 'Features', stage: v => complianceStageContributorLane('approve_held_runs_for_registry_trusted', v) }, 'auto_merge.required_checks': { tab: 'Features' }, 'sentinel.enabled': { tab: 'Security', stage: v => markSentinelDirty('enabled', v) }, 'sentinel.disabled_behaviors': { tab: 'Security' }, @@ -52148,6 +52157,7 @@

${esc(s.title)}

const enabled = document.getElementById('am-contributor-lane-enabled'); const verdict = document.getElementById('am-contributor-lane-require-reviewer-verdict'); const registry = document.getElementById('am-contributor-lane-registry-tiers-sufficient'); + const heldRuns = document.getElementById('am-contributor-lane-approve-held-runs'); const soak = document.getElementById('am-contributor-lane-soak-minutes'); const labels = document.getElementById('am-contributor-lane-exclude-labels'); const repos = document.getElementById('am-contributor-lane-repos'); @@ -52159,6 +52169,7 @@

${esc(s.title)}

soak_minutes: Number.isFinite(soakMinutes) && soakMinutes >= 0 ? soakMinutes : 30, require_reviewer_verdict: !!(verdict && verdict.classList.contains('on')), registry_tiers_sufficient: !!(registry && registry.classList.contains('on')), + approve_held_runs_for_registry_trusted: !!(heldRuns && heldRuns.classList.contains('on')), auto_promotion_scope: scope && scope.value ? scope.value : 'hive', exclude_labels: labels ? labels.value.split(',').map(s => s.trim()).filter(Boolean) : [], repos: repos ? Array.from(repos.selectedOptions).map(o => o.value).filter(Boolean) : [],