From 9f0305420aad403200f792646a8a9c3507cf42ac Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:31:59 -0500 Subject: [PATCH] docs: add beta blocker contributor guidance (#55199) * docs: add beta blocker contributor guidance * fix: tighten beta blocker labeling and flaky config test --- .github/ISSUE_TEMPLATE/bug_report.yml | 15 ++ .github/pull_request_template.md | 2 + .github/workflows/labeler.yml | 160 ++++++++++++++++++++- docs/plugins/building-plugins.md | 5 +- scripts/sync-labels.ts | 55 +++++-- src/config/io.owner-display-secret.test.ts | 10 +- 6 files changed, 233 insertions(+), 14 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 25fdcc0c805..29fbb55add2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,8 @@ body: value: | Thanks for filing this report. Keep every answer concise, reproducible, and grounded in observed evidence. Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`. + + If this is a plugin beta-release blocker, rename the issue title to `Beta blocker: - ` and apply the `beta-blocker` label after filing. - type: dropdown id: bug_type attributes: @@ -20,6 +22,19 @@ body: - Behavior bug (incorrect output/state without crash) validations: required: true + - type: dropdown + id: beta_blocker + attributes: + label: Beta release blocker + description: > + Choose `Yes` only if this blocks plugin compatibility during the current beta release window. + Selecting `Yes` does not apply the label automatically. You must also rename the issue title + to `Beta blocker: - ` for the automation to apply the `beta-blocker` label. + options: + - "No" + - "Yes" + validations: + required: true - type: textarea id: summary attributes: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6fb3d7d11fc..6c948c84115 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,8 @@ Describe the problem and fix in 2–5 bullets: +If this PR fixes a plugin beta-release blocker, title it `fix(): beta blocker - ` and link the matching `Beta blocker: - ` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation. + - Problem: - Why it matters: - What changed: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 143ebe4025e..d14d8b9d628 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -2,9 +2,9 @@ name: Labeler on: pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned triage workflow; no untrusted checkout or PR code execution - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, edited] issues: - types: [opened] + types: [opened, edited] workflow_dispatch: inputs: max_prs: @@ -209,6 +209,59 @@ jobs: // labels: [trustedLabel], // }); // } + - name: Apply beta-blocker title label + uses: actions/github-script@v8 + with: + github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} + script: | + const pullRequest = context.payload.pull_request; + if (!pullRequest) { + return; + } + + const labelName = "beta-blocker"; + const matchesBetaBlocker = /\bbeta blocker\b/i.test(pullRequest.title ?? ""); + + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: labelName, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + core.info(`Skipping ${labelName} labeling because the label does not exist in the repository.`); + return; + } + + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === labelName); + + if (matchesBetaBlocker && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + labels: [labelName], + }); + return; + } + + if (!matchesBetaBlocker && hasLabel) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + name: labelName, + }); + } - name: Apply too-many-prs label uses: actions/github-script@v8 with: @@ -419,6 +472,7 @@ jobs: const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs); const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"]; + const betaBlockerLabel = "beta-blocker"; const labelColor = "b76e79"; // const trustedLabel = "trusted-contributor"; // const experiencedLabel = "experienced-contributor"; @@ -449,6 +503,22 @@ jobs: } } + async function hasBetaBlockerLabel() { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: betaBlockerLabel, + }); + return true; + } catch (error) { + if (error?.status !== 404) { + throw error; + } + return false; + } + } + async function resolveContributorLabel(login) { if (contributorCache.has(login)) { return contributorCache.get(login); @@ -580,7 +650,37 @@ jobs: labelNames.add(label); } + async function applyBetaBlockerTitleLabel(pullRequest, labelNames) { + const matchesBetaBlocker = /\bbeta blocker\b/i.test(pullRequest.title ?? ""); + + if (matchesBetaBlocker) { + if (!labelNames.has(betaBlockerLabel)) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pullRequest.number, + labels: [betaBlockerLabel], + }); + labelNames.add(betaBlockerLabel); + } + return; + } + + if (!labelNames.has(betaBlockerLabel)) { + return; + } + + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pullRequest.number, + name: betaBlockerLabel, + }); + labelNames.delete(betaBlockerLabel); + } + await ensureSizeLabels(); + const betaBlockerLabelExists = await hasBetaBlockerLabel(); let page = 1; let processed = 0; @@ -618,6 +718,9 @@ jobs: await applySizeLabel(pullRequest, currentLabels, labelNames); await applyContributorLabel(pullRequest, labelNames); + if (betaBlockerLabelExists) { + await applyBetaBlockerTitleLabel(pullRequest, labelNames); + } processed += 1; } @@ -719,3 +822,56 @@ jobs: // labels: [trustedLabel], // }); // } + - name: Apply beta-blocker title label + uses: actions/github-script@v8 + with: + github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} + script: | + const issue = context.payload.issue; + if (!issue || issue.pull_request) { + return; + } + + const labelName = "beta-blocker"; + const matchesBetaBlocker = /^beta blocker:/i.test(issue.title ?? ""); + + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: labelName, + }); + } catch (error) { + if (error?.status !== 404) { + throw error; + } + core.info(`Skipping ${labelName} labeling because the label does not exist in the repository.`); + return; + } + + const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === labelName); + + if (matchesBetaBlocker && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [labelName], + }); + return; + } + + if (!matchesBetaBlocker && hasLabel) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: labelName, + }); + } diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 934323e1ecb..aee08d067b4 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -229,8 +229,9 @@ internal imports — never import your own plugin through its SDK path. 1. Watch for GitHub release tags on [openclaw/openclaw](https://github.com/openclaw/openclaw/releases) and subscribe via `Watch` > `Releases`. Beta tags look like `v2026.3.N-beta.1`. You can also turn on notifications for the official OpenClaw X account [@openclaw](https://x.com/openclaw) for release announcements. 2. Test your plugin against the beta tag as soon as it appears. The window before stable is typically only a few hours. 3. Post in your plugin's thread in the `plugin-forum` Discord channel after testing with either `all good` or what broke. If you do not have a thread yet, create one. -4. If something breaks, ship a fix PR to `main` and drop the link in your thread. Blockers with a PR get merged; blockers without one might ship anyway. Maintainers watch these threads during beta testing. -5. Silence means green. If you miss the window, your fix likely lands in the next cycle. +4. If something breaks, open or update an issue titled `Beta blocker: - ` and apply the `beta-blocker` label. Put the issue link in your thread. +5. Open a PR to `main` titled `fix(): beta blocker - ` and link the issue in both the PR and your Discord thread. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation. Blockers with a PR get merged; blockers without one might ship anyway. Maintainers watch these threads during beta testing. +6. Silence means green. If you miss the window, your fix likely lands in the next cycle. ## Next steps diff --git a/scripts/sync-labels.ts b/scripts/sync-labels.ts index 2d028863941..f3412f25945 100644 --- a/scripts/sync-labels.ts +++ b/scripts/sync-labels.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; type RepoLabel = { name: string; color?: string; + description?: string; }; const COLOR_BY_PREFIX = new Map([ @@ -17,8 +18,31 @@ const COLOR_BY_PREFIX = new Map([ ["size", "fbca04"], ]); +const EXTRA_LABEL_METADATA = new Map< + string, + { + color: string; + description?: string; + } +>([ + [ + "beta-blocker", + { + color: "D93F0B", + description: "Plugin beta-release blocker pending stable cutoff triage", + }, + ], +]); + const configPath = resolve(".github/labeler.yml"); -const EXTRA_LABELS = ["size: XS", "size: S", "size: M", "size: L", "size: XL"] as const; +const EXTRA_LABELS = [ + "size: XS", + "size: S", + "size: M", + "size: L", + "size: XL", + "beta-blocker", +] as const; const labelNames = [ ...new Set([...extractLabelNames(readFileSync(configPath, "utf8")), ...EXTRA_LABELS]), ]; @@ -37,12 +61,21 @@ if (!missing.length) { } for (const label of missing) { - const color = pickColor(label); - execFileSync( - "gh", - ["api", "-X", "POST", `repos/${repo}/labels`, "-f", `name=${label}`, "-f", `color=${color}`], - { stdio: "inherit" }, - ); + const metadata = resolveLabelMetadata(label); + const args = [ + "api", + "-X", + "POST", + `repos/${repo}/labels`, + "-f", + `name=${label}`, + "-f", + `color=${metadata.color}`, + ]; + if (metadata.description) { + args.push("-f", `description=${metadata.description}`); + } + execFileSync("gh", args, { stdio: "inherit" }); console.log(`Created label: ${label}`); } @@ -66,9 +99,13 @@ function extractLabelNames(contents: string): string[] { return labels; } -function pickColor(label: string): string { +function resolveLabelMetadata(label: string): { color: string; description?: string } { + const extraMetadata = EXTRA_LABEL_METADATA.get(label); + if (extraMetadata) { + return extraMetadata; + } const prefix = label.includes(":") ? label.split(":", 1)[0].trim() : label.trim(); - return COLOR_BY_PREFIX.get(prefix) ?? "ededed"; + return { color: COLOR_BY_PREFIX.get(prefix) ?? "ededed" }; } function resolveRepo(): string { diff --git a/src/config/io.owner-display-secret.test.ts b/src/config/io.owner-display-secret.test.ts index 90e31b1baa8..d83c43b6be4 100644 --- a/src/config/io.owner-display-secret.test.ts +++ b/src/config/io.owner-display-secret.test.ts @@ -9,9 +9,17 @@ async function waitForPersistedSecret(configPath: string, expectedSecret: string const deadline = Date.now() + 3_000; while (Date.now() < deadline) { const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { + let parsed: { commands?: { ownerDisplaySecret?: string }; }; + try { + parsed = JSON.parse(raw) as { + commands?: { ownerDisplaySecret?: string }; + }; + } catch { + await sleep(5); + continue; + } if (parsed.commands?.ownerDisplaySecret === expectedSecret) { return; }