docs: strengthen collaboration governance and AGENTS engineering protocol (#263)

* docs: harden collaboration policy and review automation

* ci(docs): remove unsupported lychee --exclude-mail flag

* docs(governance): reduce automation side-effects and tighten risk controls

* docs(governance): add backlog pruning and supersede protocol

* docs(agents): codify engineering principles and risk-tier workflow

* docs(readme): add centered star history section at bottom

* docs(agents): enforce privacy-safe and neutral test wording

* docs(governance): enforce privacy-safe and neutral collaboration checks

* fix(ci): satisfy rustfmt and discord schema test fields

* docs(governance): require ZeroClaw-native identity wording

* docs(agents): add ZeroClaw identity-safe naming palette

* docs(governance): codify code naming and architecture contracts

* docs(contributing): add naming and architecture good/bad examples

* docs(pr): reduce checkbox TODOs and shift to label-first metadata

* docs(pr): remove duplicate collaboration track field

* ci(labeler): auto-derive module labels and expand provider hints

* ci(labeler): auto-apply trusted contributor on PRs and issues

* fix(ci): apply rustfmt updates from latest main

* ci(labels): flatten namespaces and add contributor tiers

* chore: drop stale rustfmt-only drift

* ci: scope Rust and docs checks by change set

* ci: exclude non-markdown docs from docs-quality targets

* ci: satisfy actionlint shellcheck output style

* ci(labels): auto-correct manual contributor tier edits

* ci(labeler): auto-correct risk label edits

* ci(labeler): auto-correct size label edits

---------

Co-authored-by: Chummy <183474434+chumyin@users.noreply.github.com>
This commit is contained in:
Chummy 2026-02-16 18:59:04 +08:00 committed by GitHub
parent b5d9f72023
commit 6d56a040ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1635 additions and 154 deletions

View file

@ -1,12 +1,15 @@
name: Bug Report
description: Report a reproducible defect in ZeroClaw
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug.
Please provide a minimal reproducible case so maintainers can triage quickly.
Do not include personal/sensitive data; redact and anonymize all logs/payloads.
- type: input
id: summary
@ -17,6 +20,34 @@ body:
validations:
required: true
- type: dropdown
id: component
attributes:
label: Affected component
options:
- runtime/daemon
- provider
- channel
- memory
- security/sandbox
- tooling/ci
- docs
- unknown
validations:
required: true
- type: dropdown
id: severity
attributes:
label: Severity
options:
- S0 - data loss / security risk
- S1 - workflow blocked
- S2 - degraded behavior
- S3 - minor issue
validations:
required: true
- type: textarea
id: current
attributes:
@ -48,11 +79,23 @@ body:
validations:
required: true
- type: textarea
id: impact
attributes:
label: Impact
description: Who is affected, how often, and practical consequences.
placeholder: |
Affected users: ...
Frequency: always/intermittent
Consequence: ...
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / stack traces
description: Paste relevant logs (redact secrets).
description: Paste relevant logs (redact secrets, personal identifiers, and sensitive data).
render: text
validations:
required: false
@ -91,3 +134,15 @@ body:
- No, first-time setup
validations:
required: true
- type: checkboxes
id: checks
attributes:
label: Pre-flight checks
options:
- label: I reproduced this on the latest main branch or latest release.
required: true
- label: I redacted secrets/tokens from logs.
required: true
- label: I removed personal identifiers and replaced identity-specific data with neutral placeholders.
required: true

View file

@ -1,8 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability report
url: https://github.com/theonlyhennygod/zeroclaw/security/policy
url: https://github.com/zeroclaw-labs/zeroclaw/security/policy
about: Please report security vulnerabilities privately via SECURITY.md policy.
- name: Contribution guide
url: https://github.com/theonlyhennygod/zeroclaw/blob/main/CONTRIBUTING.md
url: https://github.com/zeroclaw-labs/zeroclaw/blob/main/CONTRIBUTING.md
about: Please read contribution and PR requirements before opening an issue.
- name: PR workflow & reviewer expectations
url: https://github.com/zeroclaw-labs/zeroclaw/blob/main/docs/pr-workflow.md
about: Read risk-based PR tracks, CI gates, and merge criteria before filing feature requests.

View file

@ -1,19 +1,31 @@
name: Feature Request
description: Propose an improvement or new capability
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Thanks for sharing your idea.
Please focus on user value, constraints, and rollout safety.
Do not include personal/sensitive data; use neutral project-scoped placeholders.
- type: input
id: summary
attributes:
label: Summary
description: One-line statement of the requested capability.
placeholder: Add a provider-level retry budget override for long-running channels.
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem statement
description: What user problem are you trying to solve?
placeholder: Teams need a way to ...
description: What user pain does this solve and why is current behavior insufficient?
placeholder: Teams operating in unstable networks cannot tune retries per provider...
validations:
required: true
@ -21,8 +33,17 @@ body:
id: proposal
attributes:
label: Proposed solution
description: Describe the preferred solution.
placeholder: Add a new subcommand / trait implementation ...
description: Describe preferred behavior and interfaces.
placeholder: Add `[provider.retry]` config and enforce bounds in config validation.
validations:
required: true
- type: textarea
id: non_goals
attributes:
label: Non-goals / out of scope
description: Clarify what should not be included in the first iteration.
placeholder: No UI changes, no cross-provider dynamic adaptation in v1.
validations:
required: true
@ -31,16 +52,28 @@ body:
attributes:
label: Alternatives considered
description: What alternatives did you evaluate?
placeholder: Keep current behavior, use external tool, etc.
placeholder: Keep current behavior, use wrapper scripts, etc.
validations:
required: false
- type: textarea
id: acceptance
attributes:
label: Acceptance criteria
description: What outcomes would make this request complete?
placeholder: |
- Config key is documented and validated
- Runtime path uses configured retry budget
- Regression tests cover fallback and invalid config
validations:
required: true
- type: textarea
id: architecture
attributes:
label: Architecture impact
description: Which subsystem(s) are affected?
placeholder: providers/, channels/, memory/, runtime/, security/ ...
placeholder: providers/, channels/, memory/, runtime/, security/, docs/ ...
validations:
required: true
@ -62,3 +95,13 @@ body:
- Yes
validations:
required: true
- type: checkboxes
id: hygiene
attributes:
label: Data hygiene checks
options:
- label: I removed personal/sensitive data from examples, payloads, and logs.
required: true
- label: I used neutral, project-focused wording and placeholders.
required: true

35
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,35 @@
version: 2
updates:
- package-ecosystem: cargo
directory: "/"
schedule:
interval: weekly
target-branch: main
open-pull-requests-limit: 5
labels:
- "dependencies"
groups:
rust-minor-patch:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
target-branch: main
open-pull-requests-limit: 3
labels:
- "ci"
- "dependencies"
groups:
actions-minor-patch:
patterns:
- "*"
update-types:
- minor
- patch

112
.github/labeler.yml vendored
View file

@ -1,59 +1,147 @@
"type: docs":
"docs":
- changed-files:
- any-glob-to-any-file:
- "docs/**"
- "**/*.md"
- "**/*.mdx"
- "LICENSE"
- ".markdownlint-cli2.yaml"
"type: dependencies":
"dependencies":
- changed-files:
- any-glob-to-any-file:
- "Cargo.toml"
- "Cargo.lock"
- "deny.toml"
- ".github/dependabot.yml"
"type: ci":
"ci":
- changed-files:
- any-glob-to-any-file:
- ".github/**"
- ".githooks/**"
"area: providers":
"core":
- changed-files:
- any-glob-to-any-file:
- "src/providers/**"
- "src/*.rs"
"area: channels":
"agent":
- changed-files:
- any-glob-to-any-file:
- "src/agent/**"
"channel":
- changed-files:
- any-glob-to-any-file:
- "src/channels/**"
"area: memory":
"gateway":
- changed-files:
- any-glob-to-any-file:
- "src/gateway/**"
"config":
- changed-files:
- any-glob-to-any-file:
- "src/config/**"
"cron":
- changed-files:
- any-glob-to-any-file:
- "src/cron/**"
"daemon":
- changed-files:
- any-glob-to-any-file:
- "src/daemon/**"
"doctor":
- changed-files:
- any-glob-to-any-file:
- "src/doctor/**"
"health":
- changed-files:
- any-glob-to-any-file:
- "src/health/**"
"heartbeat":
- changed-files:
- any-glob-to-any-file:
- "src/heartbeat/**"
"integration":
- changed-files:
- any-glob-to-any-file:
- "src/integrations/**"
"memory":
- changed-files:
- any-glob-to-any-file:
- "src/memory/**"
"area: security":
"security":
- changed-files:
- any-glob-to-any-file:
- "src/security/**"
"area: runtime":
"runtime":
- changed-files:
- any-glob-to-any-file:
- "src/runtime/**"
"area: tools":
"onboard":
- changed-files:
- any-glob-to-any-file:
- "src/onboard/**"
"provider":
- changed-files:
- any-glob-to-any-file:
- "src/providers/**"
"service":
- changed-files:
- any-glob-to-any-file:
- "src/service/**"
"skillforge":
- changed-files:
- any-glob-to-any-file:
- "src/skillforge/**"
"skills":
- changed-files:
- any-glob-to-any-file:
- "src/skills/**"
"tool":
- changed-files:
- any-glob-to-any-file:
- "src/tools/**"
"area: observability":
"tunnel":
- changed-files:
- any-glob-to-any-file:
- "src/tunnel/**"
"observability":
- changed-files:
- any-glob-to-any-file:
- "src/observability/**"
"area: tests":
"tests":
- changed-files:
- any-glob-to-any-file:
- "tests/**"
"scripts":
- changed-files:
- any-glob-to-any-file:
- "scripts/**"
"dev":
- changed-files:
- any-glob-to-any-file:
- "dev/**"

View file

@ -7,33 +7,30 @@ Describe this PR in 2-5 bullets:
- What changed:
- What did **not** change (scope boundary):
## Change Type
## Label Snapshot (required)
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Docs
- [ ] Security hardening
- [ ] Chore / infra
- Risk label (`risk: low|medium|high`):
- Size label (`size: XS|S|M|L|XL`, auto-managed/read-only):
- Scope labels (`core|agent|channel|config|cron|daemon|doctor|gateway|health|heartbeat|integration|memory|observability|onboard|provider|runtime|security|service|skillforge|skills|tool|tunnel|docs|dependencies|ci|tests|scripts|dev`, comma-separated):
- Module labels (`<module>:<component>`, for example `channel:telegram`, `provider:kimi`, `tool:shell`):
- Contributor tier label (`experienced contributor|principal contributor|distinguished contributor`, auto-managed/read-only; author merged PRs >=10/20/50):
- If any auto-label is incorrect, note requested correction:
## Scope
## Change Metadata
- [ ] Core runtime / daemon
- [ ] Provider integration
- [ ] Channel integration
- [ ] Memory / storage
- [ ] Security / sandbox
- [ ] CI / release / tooling
- [ ] Documentation
- Change type (`bug|feature|refactor|docs|security|chore`):
- Primary scope (`runtime|provider|channel|memory|security|ci|docs|multi`):
## Linked Issue
- Closes #
- Related #
- Depends on # (if stacked)
- Supersedes # (if replacing older PR)
## Testing
## Validation Evidence (required)
Commands and result summary (required):
Commands and result summary:
```bash
cargo fmt --all -- --check
@ -41,9 +38,10 @@ cargo clippy --all-targets -- -D warnings
cargo test
```
If any command is intentionally skipped, explain why.
- Evidence provided (test/log/trace/screenshot/perf):
- If any command is intentionally skipped, explain why:
## Security Impact
## Security Impact (required)
- New permissions/capabilities? (`Yes/No`)
- New external network calls? (`Yes/No`)
@ -51,20 +49,49 @@ If any command is intentionally skipped, explain why.
- File system access scope changed? (`Yes/No`)
- If any `Yes`, describe risk and mitigation:
## Privacy and Data Hygiene (required)
- Data-hygiene status (`pass|needs-follow-up`):
- Redaction/anonymization notes:
- Neutral wording confirmation (use ZeroClaw/project-native labels if identity-like wording is needed):
## Compatibility / Migration
- Backward compatible? (`Yes/No`)
- Config/env changes? (`Yes/No`)
- Migration needed? (`Yes/No`)
- If yes, exact upgrade steps:
## Human Verification (required)
What was personally validated beyond CI:
- Verified scenarios:
- Edge cases checked:
- What was not verified:
## Side Effects / Blast Radius (required)
- Affected subsystems/workflows:
- Potential unintended effects:
- Guardrails/monitoring for early detection:
## Agent Collaboration Notes (recommended)
- [ ] If agent/automation tools were used, I added brief workflow notes.
- [ ] I included concrete validation evidence for this change.
- [ ] I can explain design choices and rollback steps.
If agent tools were used, optional context:
- Tool(s):
- Prompt/plan summary:
- Agent tools used (if any):
- Workflow/plan summary (if any):
- Verification focus:
- Confirmation: naming + architecture boundaries followed (`AGENTS.md` + `CONTRIBUTING.md`):
## Rollback Plan
## Rollback Plan (required)
- Fast rollback command/path:
- Feature flags or config toggles (if any):
- Observable failure symptoms:
## Risks and Mitigations
List real risks in this PR (or write `None`).
- Risk:
- Mitigation:

View file

@ -2,14 +2,123 @@ name: Auto Response
on:
issues:
types: [opened]
types: [opened, reopened, labeled, unlabeled]
pull_request_target:
types: [opened]
types: [opened, labeled, unlabeled]
permissions: {}
jobs:
contributor-tier-issues:
if: >-
(github.event_name == 'issues' &&
(github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'labeled' || github.event.action == 'unlabeled')) ||
(github.event_name == 'pull_request_target' &&
(github.event.action == 'labeled' || github.event.action == 'unlabeled'))
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Apply contributor tier label for issue author
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue = context.payload.issue;
const pullRequest = context.payload.pull_request;
const target = issue ?? pullRequest;
const legacyTrustedContributorLabel = "trusted contributor";
const contributorTierRules = [
{ label: "distinguished contributor", minMergedPRs: 50 },
{ label: "principal contributor", minMergedPRs: 20 },
{ label: "experienced contributor", minMergedPRs: 10 },
];
const contributorTierLabels = contributorTierRules.map((rule) => rule.label);
const contributorTierColor = "39FF14";
const managedContributorLabels = new Set([
legacyTrustedContributorLabel,
...contributorTierLabels,
]);
const action = context.payload.action;
const changedLabel = context.payload.label?.name;
if (!target) return;
if ((action === "labeled" || action === "unlabeled") && !managedContributorLabels.has(changedLabel)) {
return;
}
const author = target.user;
if (!author || author.type === "Bot") return;
async function ensureContributorTierLabels() {
for (const label of contributorTierLabels) {
try {
const { data: existing } = await github.rest.issues.getLabel({ owner, repo, name: label });
const currentColor = (existing.color || "").toUpperCase();
if (currentColor !== contributorTierColor) {
await github.rest.issues.updateLabel({
owner,
repo,
name: label,
new_name: label,
color: contributorTierColor,
});
}
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner,
repo,
name: label,
color: contributorTierColor,
});
}
}
}
function selectContributorTier(mergedCount) {
const matchedTier = contributorTierRules.find((rule) => mergedCount >= rule.minMergedPRs);
return matchedTier ? matchedTier.label : null;
}
let contributorTierLabel = null;
try {
const { data: mergedSearch } = await github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:pr is:merged author:${author.login}`,
per_page: 1,
});
const mergedCount = mergedSearch.total_count || 0;
contributorTierLabel = selectContributorTier(mergedCount);
} catch (error) {
core.warning(`failed to evaluate contributor tier status: ${error.message}`);
return;
}
await ensureContributorTierLabels();
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: target.number,
});
const keepLabels = currentLabels
.map((label) => label.name)
.filter((label) => label !== legacyTrustedContributorLabel && !contributorTierLabels.includes(label));
if (contributorTierLabel) {
keepLabels.push(contributorTierLabel);
}
await github.rest.issues.setLabels({
owner,
repo,
issue_number: target.number,
labels: [...new Set(keepLabels)],
});
first-interaction:
if: github.event.action == 'opened'
runs-on: ubuntu-latest
permissions:
issues: write
@ -38,3 +147,105 @@ jobs:
- Scope is focused (prefer one concern per PR)
See `CONTRIBUTING.md` and `docs/pr-workflow.md` for full collaboration rules.
labeled-routes:
if: github.event.action == 'labeled'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Handle label-driven responses
uses: actions/github-script@v7
with:
script: |
const label = context.payload.label?.name;
if (!label) return;
const issue = context.payload.issue;
const pullRequest = context.payload.pull_request;
const target = issue ?? pullRequest;
if (!target) return;
const isIssue = Boolean(issue);
const issueNumber = target.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
const rules = [
{
label: "r:support",
close: true,
closeIssuesOnly: true,
closeReason: "not_planned",
message:
"This looks like a usage/support request. Please use README + docs first, then open a focused bug with repro details if behavior is incorrect.",
},
{
label: "r:needs-repro",
close: false,
message:
"Thanks for the report. Please add deterministic repro steps, exact environment, and redacted logs so maintainers can triage quickly.",
},
{
label: "invalid",
close: true,
closeIssuesOnly: true,
closeReason: "not_planned",
message:
"Closing as invalid based on current information. If this is still relevant, open a new issue with updated evidence and reproducible steps.",
},
{
label: "duplicate",
close: true,
closeIssuesOnly: true,
closeReason: "not_planned",
message:
"Closing as duplicate. Please continue discussion in the canonical linked issue/PR.",
},
];
const rule = rules.find((entry) => entry.label === label);
if (!rule) return;
const marker = `<!-- auto-response:${rule.label} -->`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyCommented = comments.some((comment) =>
(comment.body || "").includes(marker)
);
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${rule.message}\n\n${marker}`,
});
}
if (!rule.close) return;
if (rule.closeIssuesOnly && !isIssue) return;
if (target.state === "closed") return;
if (isIssue) {
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: rule.closeReason || "not_planned",
});
} else {
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
});
}

View file

@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main, develop]
branches: [main]
pull_request:
branches: [main]
@ -22,6 +22,9 @@ jobs:
runs-on: ubuntu-latest
outputs:
docs_only: ${{ steps.scope.outputs.docs_only }}
docs_changed: ${{ steps.scope.outputs.docs_changed }}
rust_changed: ${{ steps.scope.outputs.rust_changed }}
docs_files: ${{ steps.scope.outputs.docs_files }}
steps:
- uses: actions/checkout@v4
with:
@ -33,6 +36,13 @@ jobs:
run: |
set -euo pipefail
write_empty_docs_files() {
{
echo "docs_files<<EOF"
echo "EOF"
} >> "$GITHUB_OUTPUT"
}
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
@ -40,17 +50,30 @@ jobs:
fi
if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
{
echo "docs_only=false"
echo "docs_changed=false"
echo "rust_changed=true"
} >> "$GITHUB_OUTPUT"
write_empty_docs_files
exit 0
fi
CHANGED="$(git diff --name-only "$BASE" HEAD || true)"
if [ -z "$CHANGED" ]; then
echo "docs_only=false" >> "$GITHUB_OUTPUT"
{
echo "docs_only=false"
echo "docs_changed=false"
echo "rust_changed=false"
} >> "$GITHUB_OUTPUT"
write_empty_docs_files
exit 0
fi
docs_only=true
docs_changed=false
rust_changed=false
docs_files=()
while IFS= read -r file; do
[ -z "$file" ] && continue
@ -58,21 +81,43 @@ jobs:
|| [[ "$file" == *.md ]] \
|| [[ "$file" == *.mdx ]] \
|| [[ "$file" == "LICENSE" ]] \
|| [[ "$file" == ".markdownlint-cli2.yaml" ]] \
|| [[ "$file" == .github/ISSUE_TEMPLATE/* ]] \
|| [[ "$file" == .github/pull_request_template.md ]]; then
if [[ "$file" == *.md ]] \
|| [[ "$file" == *.mdx ]] \
|| [[ "$file" == "LICENSE" ]] \
|| [[ "$file" == .github/pull_request_template.md ]]; then
docs_changed=true
docs_files+=("$file")
fi
continue
fi
docs_only=false
break
if [[ "$file" == src/* ]] \
|| [[ "$file" == tests/* ]] \
|| [[ "$file" == "Cargo.toml" ]] \
|| [[ "$file" == "Cargo.lock" ]] \
|| [[ "$file" == "deny.toml" ]]; then
rust_changed=true
fi
done <<< "$CHANGED"
echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT"
{
echo "docs_only=$docs_only"
echo "docs_changed=$docs_changed"
echo "rust_changed=$rust_changed"
echo "docs_files<<EOF"
printf '%s\n' "${docs_files[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
lint:
name: Format & Lint
needs: [changes]
if: needs.changes.outputs.docs_only != 'true'
if: needs.changes.outputs.rust_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@ -92,7 +137,7 @@ jobs:
test:
name: Test
needs: [changes]
if: needs.changes.outputs.docs_only != 'true'
if: needs.changes.outputs.rust_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@ -107,7 +152,7 @@ jobs:
build:
name: Build (Smoke)
needs: [changes]
if: needs.changes.outputs.docs_only != 'true'
if: needs.changes.outputs.rust_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
@ -129,10 +174,45 @@ jobs:
- name: Skip heavy jobs for docs-only change
run: echo "Docs-only change detected. Rust lint/test/build skipped."
non-rust:
name: Non-Rust Fast Path
needs: [changes]
if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.rust_changed != 'true'
runs-on: ubuntu-latest
steps:
- name: Skip Rust jobs for non-Rust change scope
run: echo "No Rust-impacting files changed. Rust lint/test/build skipped."
docs-quality:
name: Docs Quality
needs: [changes]
if: needs.changes.outputs.docs_changed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Markdown lint
uses: DavidAnson/markdownlint-cli2-action@v20
with:
globs: ${{ needs.changes.outputs.docs_files }}
- name: Link check (offline)
uses: lycheeverse/lychee-action@v2
with:
fail: true
args: >-
--offline
--no-progress
--format detailed
${{ needs.changes.outputs.docs_files }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ci-required:
name: CI Required Gate
if: always()
needs: [changes, lint, test, build, docs-only]
needs: [changes, lint, test, build, docs-only, non-rust, docs-quality]
runs-on: ubuntu-latest
steps:
- name: Enforce required status
@ -140,11 +220,31 @@ jobs:
run: |
set -euo pipefail
docs_changed="${{ needs.changes.outputs.docs_changed }}"
rust_changed="${{ needs.changes.outputs.rust_changed }}"
docs_result="${{ needs.docs-quality.result }}"
if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then
echo "docs=${docs_result}"
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
echo "Docs-only change touched markdown docs, but docs-quality did not pass."
exit 1
fi
echo "Docs-only fast path passed."
exit 0
fi
if [ "$rust_changed" != "true" ]; then
echo "rust_changed=false (non-rust fast path)"
echo "docs=${docs_result}"
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
echo "Docs changed but docs-quality did not pass."
exit 1
fi
echo "Non-rust fast path passed."
exit 0
fi
lint_result="${{ needs.lint.result }}"
test_result="${{ needs.test.result }}"
build_result="${{ needs.build.result }}"
@ -152,10 +252,16 @@ jobs:
echo "lint=${lint_result}"
echo "test=${test_result}"
echo "build=${build_result}"
echo "docs=${docs_result}"
if [ "$lint_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then
echo "Required CI jobs did not pass."
exit 1
fi
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
echo "Docs changed but docs-quality did not pass."
exit 1
fi
echo "All required CI jobs passed."

View file

@ -2,7 +2,7 @@ name: PR Labeler
on:
pull_request_target:
types: [opened, reopened, synchronize, edited]
types: [opened, reopened, synchronize, edited, labeled, unlabeled]
permissions:
contents: read
@ -17,15 +17,373 @@ jobs:
uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
sync-labels: true
- name: Apply size label
- name: Apply size/risk/module labels
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;
const action = context.payload.action;
const changedLabel = context.payload.label?.name;
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
const labelColor = "BFDADC";
const changedLines = (pr.additions || 0) + (pr.deletions || 0);
const computedRiskLabels = ["risk: low", "risk: medium", "risk: high"];
const manualRiskOverrideLabel = "risk: manual";
const managedEnforcedLabels = new Set([
...sizeLabels,
manualRiskOverrideLabel,
...computedRiskLabels,
]);
const legacyTrustedContributorLabel = "trusted contributor";
if ((action === "labeled" || action === "unlabeled") && !managedEnforcedLabels.has(changedLabel)) {
core.info(`skip non-size/risk label event: ${changedLabel || "unknown"}`);
return;
}
const contributorTierRules = [
{ label: "distinguished contributor", minMergedPRs: 50 },
{ label: "principal contributor", minMergedPRs: 20 },
{ label: "experienced contributor", minMergedPRs: 10 },
];
const contributorTierLabels = contributorTierRules.map((rule) => rule.label);
const contributorTierColor = "39FF14";
const managedPathLabels = [
"docs",
"dependencies",
"ci",
"core",
"agent",
"channel",
"config",
"cron",
"daemon",
"doctor",
"gateway",
"health",
"heartbeat",
"integration",
"memory",
"observability",
"onboard",
"provider",
"runtime",
"security",
"service",
"skillforge",
"skills",
"tool",
"tunnel",
"tests",
"scripts",
"dev",
];
const moduleNamespaceRules = [
{ root: "src/agent/", prefix: "agent", coreEntries: new Set(["mod.rs"]) },
{ root: "src/channels/", prefix: "channel", coreEntries: new Set(["mod.rs", "traits.rs"]) },
{ root: "src/config/", prefix: "config", coreEntries: new Set(["mod.rs", "schema.rs"]) },
{ root: "src/cron/", prefix: "cron", coreEntries: new Set(["mod.rs"]) },
{ root: "src/daemon/", prefix: "daemon", coreEntries: new Set(["mod.rs"]) },
{ root: "src/doctor/", prefix: "doctor", coreEntries: new Set(["mod.rs"]) },
{ root: "src/gateway/", prefix: "gateway", coreEntries: new Set(["mod.rs"]) },
{ root: "src/health/", prefix: "health", coreEntries: new Set(["mod.rs"]) },
{ root: "src/heartbeat/", prefix: "heartbeat", coreEntries: new Set(["mod.rs"]) },
{ root: "src/integrations/", prefix: "integration", coreEntries: new Set(["mod.rs", "registry.rs"]) },
{ root: "src/memory/", prefix: "memory", coreEntries: new Set(["mod.rs", "traits.rs"]) },
{ root: "src/observability/", prefix: "observability", coreEntries: new Set(["mod.rs", "traits.rs"]) },
{ root: "src/onboard/", prefix: "onboard", coreEntries: new Set(["mod.rs"]) },
{ root: "src/providers/", prefix: "provider", coreEntries: new Set(["mod.rs", "traits.rs"]) },
{ root: "src/runtime/", prefix: "runtime", coreEntries: new Set(["mod.rs", "traits.rs"]) },
{ root: "src/security/", prefix: "security", coreEntries: new Set(["mod.rs"]) },
{ root: "src/service/", prefix: "service", coreEntries: new Set(["mod.rs"]) },
{ root: "src/skillforge/", prefix: "skillforge", coreEntries: new Set(["mod.rs"]) },
{ root: "src/skills/", prefix: "skills", coreEntries: new Set(["mod.rs"]) },
{ root: "src/tools/", prefix: "tool", coreEntries: new Set(["mod.rs", "traits.rs"]) },
{ root: "src/tunnel/", prefix: "tunnel", coreEntries: new Set(["mod.rs"]) },
];
const managedModulePrefixes = [...new Set(moduleNamespaceRules.map((rule) => `${rule.prefix}:`))];
const staticLabelColors = {
"size: XS": "BFDADC",
"size: S": "BFDADC",
"size: M": "BFDADC",
"size: L": "BFDADC",
"size: XL": "BFDADC",
"risk: low": "2EA043",
"risk: medium": "FBCA04",
"risk: high": "D73A49",
"risk: manual": "1F6FEB",
docs: "1D76DB",
dependencies: "C26F00",
ci: "8250DF",
core: "24292F",
agent: "2EA043",
channel: "1D76DB",
config: "0969DA",
cron: "9A6700",
daemon: "57606A",
doctor: "0E8A8A",
gateway: "D73A49",
health: "0E8A8A",
heartbeat: "0E8A8A",
integration: "8250DF",
memory: "1F883D",
observability: "6E7781",
onboard: "B62DBA",
provider: "5319E7",
runtime: "C26F00",
security: "B60205",
service: "0052CC",
skillforge: "A371F7",
skills: "6F42C1",
tool: "D73A49",
tunnel: "0052CC",
tests: "0E8A16",
scripts: "B08800",
dev: "6E7781",
};
for (const label of contributorTierLabels) {
staticLabelColors[label] = contributorTierColor;
}
const modulePrefixColors = {
"agent:": "2EA043",
"channel:": "1D76DB",
"config:": "0969DA",
"cron:": "9A6700",
"daemon:": "57606A",
"doctor:": "0E8A8A",
"gateway:": "D73A49",
"health:": "0E8A8A",
"heartbeat:": "0E8A8A",
"integration:": "8250DF",
"memory:": "1F883D",
"observability:": "6E7781",
"onboard:": "B62DBA",
"provider:": "5319E7",
"runtime:": "C26F00",
"security:": "B60205",
"service:": "0052CC",
"skillforge:": "A371F7",
"skills:": "6F42C1",
"tool:": "D73A49",
"tunnel:": "0052CC",
};
const providerKeywordHints = [
"deepseek",
"moonshot",
"kimi",
"qwen",
"mistral",
"doubao",
"baichuan",
"yi",
"siliconflow",
"vertex",
"azure",
"perplexity",
"venice",
"vercel",
"cloudflare",
"synthetic",
"opencode",
"zai",
"glm",
"minimax",
"bedrock",
"qianfan",
"groq",
"together",
"fireworks",
"cohere",
"openai",
"openrouter",
"anthropic",
"gemini",
"ollama",
];
const channelKeywordHints = [
"telegram",
"discord",
"slack",
"whatsapp",
"matrix",
"irc",
"imessage",
"email",
"cli",
];
function isDocsLike(path) {
return (
path.startsWith("docs/") ||
path.endsWith(".md") ||
path.endsWith(".mdx") ||
path === "LICENSE" ||
path === ".markdownlint-cli2.yaml" ||
path === ".github/pull_request_template.md" ||
path.startsWith(".github/ISSUE_TEMPLATE/")
);
}
function normalizeLabelSegment(segment) {
return (segment || "")
.toLowerCase()
.replace(/\.rs$/g, "")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
function containsKeyword(text, keyword) {
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`(^|[^a-z0-9_])${escaped}([^a-z0-9_]|$)`, "i");
return pattern.test(text);
}
function colorForLabel(label) {
if (staticLabelColors[label]) return staticLabelColors[label];
const matchedPrefix = Object.keys(modulePrefixColors).find((prefix) => label.startsWith(prefix));
if (matchedPrefix) return modulePrefixColors[matchedPrefix];
return "BFDADC";
}
async function ensureLabel(name) {
const expectedColor = colorForLabel(name);
try {
const { data: existing } = await github.rest.issues.getLabel({ owner, repo, name });
const currentColor = (existing.color || "").toUpperCase();
if (currentColor !== expectedColor) {
await github.rest.issues.updateLabel({
owner,
repo,
name,
new_name: name,
color: expectedColor,
});
}
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner,
repo,
name,
color: expectedColor,
});
}
}
function selectContributorTier(mergedCount) {
const matchedTier = contributorTierRules.find((rule) => mergedCount >= rule.minMergedPRs);
return matchedTier ? matchedTier.label : null;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
});
const detectedModuleLabels = new Set();
for (const file of files) {
const path = (file.filename || "").toLowerCase();
for (const rule of moduleNamespaceRules) {
if (!path.startsWith(rule.root)) continue;
const relative = path.slice(rule.root.length);
if (!relative) continue;
const first = relative.split("/")[0];
const firstStem = first.endsWith(".rs") ? first.slice(0, -3) : first;
let segment = firstStem;
if (rule.coreEntries.has(first) || rule.coreEntries.has(firstStem)) {
segment = "core";
}
segment = normalizeLabelSegment(segment);
if (!segment) continue;
detectedModuleLabels.add(`${rule.prefix}:${segment}`);
}
}
const providerRelevantFiles = files.filter((file) => {
const path = file.filename || "";
return (
path.startsWith("src/providers/") ||
path.startsWith("src/integrations/") ||
path.startsWith("src/onboard/") ||
path.startsWith("src/config/")
);
});
if (providerRelevantFiles.length > 0) {
const searchableText = [
pr.title || "",
pr.body || "",
...providerRelevantFiles.map((file) => file.filename || ""),
...providerRelevantFiles.map((file) => file.patch || ""),
]
.join("\n")
.toLowerCase();
for (const keyword of providerKeywordHints) {
if (containsKeyword(searchableText, keyword)) {
detectedModuleLabels.add(`provider:${keyword}`);
}
}
}
const channelRelevantFiles = files.filter((file) => {
const path = file.filename || "";
return (
path.startsWith("src/channels/") ||
path.startsWith("src/onboard/") ||
path.startsWith("src/config/")
);
});
if (channelRelevantFiles.length > 0) {
const searchableText = [
pr.title || "",
pr.body || "",
...channelRelevantFiles.map((file) => file.filename || ""),
...channelRelevantFiles.map((file) => file.patch || ""),
]
.join("\n")
.toLowerCase();
for (const keyword of channelKeywordHints) {
if (containsKeyword(searchableText, keyword)) {
detectedModuleLabels.add(`channel:${keyword}`);
}
}
}
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: pr.number,
});
const currentLabelNames = currentLabels.map((label) => label.name);
const excludedLockfiles = new Set(["Cargo.lock"]);
const changedLines = files.reduce((total, file) => {
const path = file.filename || "";
if (isDocsLike(path) || excludedLockfiles.has(path)) {
return total;
}
return total + (file.additions || 0) + (file.deletions || 0);
}, 0);
let sizeLabel = "size: XL";
if (changedLines <= 80) sizeLabel = "size: XS";
@ -33,38 +391,85 @@ jobs:
else if (changedLines <= 500) sizeLabel = "size: M";
else if (changedLines <= 1000) sizeLabel = "size: L";
for (const label of sizeLabels) {
const hasHighRiskPath = files.some((file) => {
const path = file.filename || "";
return (
path.startsWith("src/security/") ||
path.startsWith("src/runtime/") ||
path.startsWith("src/gateway/") ||
path.startsWith("src/tools/") ||
path.startsWith(".github/workflows/")
);
});
const hasMediumRiskPath = files.some((file) => {
const path = file.filename || "";
return (
path.startsWith("src/") ||
path === "Cargo.toml" ||
path === "Cargo.lock" ||
path === "deny.toml" ||
path.startsWith(".githooks/")
);
});
let riskLabel = "risk: low";
if (hasHighRiskPath) {
riskLabel = "risk: high";
} else if (hasMediumRiskPath) {
riskLabel = "risk: medium";
}
const labelsToEnsure = new Set([
...sizeLabels,
...computedRiskLabels,
manualRiskOverrideLabel,
...managedPathLabels,
...contributorTierLabels,
...detectedModuleLabels,
]);
for (const label of labelsToEnsure) {
await ensureLabel(label);
}
let contributorTierLabel = null;
const authorLogin = pr.user?.login;
if (authorLogin && pr.user?.type !== "Bot") {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
const { data: mergedSearch } = await github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:pr is:merged author:${authorLogin}`,
per_page: 1,
});
const mergedCount = mergedSearch.total_count || 0;
contributorTierLabel = selectContributorTier(mergedCount);
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: labelColor,
});
core.warning(`failed to compute contributor tier label: ${error.message}`);
}
}
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
const hasManualRiskOverride = currentLabelNames.includes(manualRiskOverrideLabel);
const keepNonManagedLabels = currentLabelNames.filter((label) => {
if (label === manualRiskOverrideLabel) return true;
if (label === legacyTrustedContributorLabel) return false;
if (contributorTierLabels.includes(label)) return false;
if (sizeLabels.includes(label) || computedRiskLabels.includes(label)) return false;
if (managedModulePrefixes.some((prefix) => label.startsWith(prefix))) return false;
return true;
});
const keepLabels = currentLabels
.map((label) => label.name)
.filter((label) => !sizeLabels.includes(label));
const manualRiskSelection =
currentLabelNames.find((label) => computedRiskLabels.includes(label)) || riskLabel;
const moduleLabelList = [...detectedModuleLabels];
const contributorLabelList = contributorTierLabel ? [contributorTierLabel] : [];
const nextLabels = hasManualRiskOverride
? [...new Set([...keepNonManagedLabels, ...moduleLabelList, ...contributorLabelList, sizeLabel, manualRiskSelection])]
: [...new Set([...keepNonManagedLabels, ...moduleLabelList, ...contributorLabelList, sizeLabel, riskLabel])];
const nextLabels = [...new Set([...keepLabels, sizeLabel])];
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
owner,
repo,
issue_number: pr.number,
labels: nextLabels,
});