chore: merge devsecops into main (#546)
* fix(workflows): standardize runner configuration for security jobs * ci(actionlint): add Blacksmith runner label to config Add blacksmith-2vcpu-ubuntu-2404 to actionlint self-hosted-runner labels config to suppress "unknown label" warnings during workflow linting. This label is used across all workflows after the Blacksmith migration. * fix(actionlint): adjust indentation for self-hosted runner labels * feat(security): enhance security workflow with CodeQL analysis steps * fix(security): update CodeQL action to version 4 for improved analysis * fix(security): remove duplicate permissions in security workflow * fix(security): revert CodeQL action to v3 for stability The v4 version was causing workflow file validation failures. Reverting to proven v3 version that is working on main branch. * fix(security): remove duplicate permissions causing workflow validation failure The permissions block had duplicate security-events and actions keys, which caused YAML validation errors and prevented workflow execution. Fixes: workflow file validation failures on main branch * fix(security): remove pull_request trigger to reduce costs * fix(security): restore PR trigger but skip codeql on PRs * fix(security): resolve YAML syntax error in security workflow * refactor(security): split CodeQL into dedicated scheduled workflow * fix(security): update workflow name to Rust Package Security Audit * fix(codeql): remove push trigger, keep schedule and on-demand only * feat(codeql): add CodeQL configuration file to ignore specific paths * Potential fix for code scanning alert no. 39: Hard-coded cryptographic value Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix(ci): resolve auto-response workflow merge markers * fix(build): restore ChannelMessage reply_target usage * ci(workflows): run workflow sanity on workflow pushes for all branches * ci(workflows): rename auto-response workflow to PR Auto Responder * ci(workflows): require owner approval for workflow file changes --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
parent
bb641d28c2
commit
500e6bd0ec
6 changed files with 120 additions and 8 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
|
@ -10,7 +10,7 @@
|
||||||
/Cargo.lock @theonlyhennygod
|
/Cargo.lock @theonlyhennygod
|
||||||
|
|
||||||
# CI
|
# CI
|
||||||
/.github/workflows/** @willsarg
|
/.github/workflows/** @theonlyhennygod @willsarg
|
||||||
/.github/codeql/** @willsarg
|
/.github/codeql/** @willsarg
|
||||||
/.github/dependabot.yml @willsarg
|
/.github/dependabot.yml @willsarg
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/auto-response.yml
vendored
2
.github/workflows/auto-response.yml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: Auto Response
|
name: PR Auto Responder
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
|
|
|
||||||
113
.github/workflows/ci.yml
vendored
113
.github/workflows/ci.yml
vendored
|
|
@ -24,6 +24,7 @@ jobs:
|
||||||
docs_only: ${{ steps.scope.outputs.docs_only }}
|
docs_only: ${{ steps.scope.outputs.docs_only }}
|
||||||
docs_changed: ${{ steps.scope.outputs.docs_changed }}
|
docs_changed: ${{ steps.scope.outputs.docs_changed }}
|
||||||
rust_changed: ${{ steps.scope.outputs.rust_changed }}
|
rust_changed: ${{ steps.scope.outputs.rust_changed }}
|
||||||
|
workflow_changed: ${{ steps.scope.outputs.workflow_changed }}
|
||||||
docs_files: ${{ steps.scope.outputs.docs_files }}
|
docs_files: ${{ steps.scope.outputs.docs_files }}
|
||||||
base_sha: ${{ steps.scope.outputs.base_sha }}
|
base_sha: ${{ steps.scope.outputs.base_sha }}
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -55,6 +56,7 @@ jobs:
|
||||||
echo "docs_only=false"
|
echo "docs_only=false"
|
||||||
echo "docs_changed=false"
|
echo "docs_changed=false"
|
||||||
echo "rust_changed=true"
|
echo "rust_changed=true"
|
||||||
|
echo "workflow_changed=false"
|
||||||
echo "base_sha="
|
echo "base_sha="
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
write_empty_docs_files
|
write_empty_docs_files
|
||||||
|
|
@ -67,6 +69,7 @@ jobs:
|
||||||
echo "docs_only=false"
|
echo "docs_only=false"
|
||||||
echo "docs_changed=false"
|
echo "docs_changed=false"
|
||||||
echo "rust_changed=false"
|
echo "rust_changed=false"
|
||||||
|
echo "workflow_changed=false"
|
||||||
echo "base_sha=$BASE"
|
echo "base_sha=$BASE"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
write_empty_docs_files
|
write_empty_docs_files
|
||||||
|
|
@ -76,10 +79,15 @@ jobs:
|
||||||
docs_only=true
|
docs_only=true
|
||||||
docs_changed=false
|
docs_changed=false
|
||||||
rust_changed=false
|
rust_changed=false
|
||||||
|
workflow_changed=false
|
||||||
docs_files=()
|
docs_files=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[ -z "$file" ] && continue
|
[ -z "$file" ] && continue
|
||||||
|
|
||||||
|
if [[ "$file" == .github/workflows/* ]]; then
|
||||||
|
workflow_changed=true
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$file" == docs/* ]] \
|
if [[ "$file" == docs/* ]] \
|
||||||
|| [[ "$file" == *.md ]] \
|
|| [[ "$file" == *.md ]] \
|
||||||
|| [[ "$file" == *.mdx ]] \
|
|| [[ "$file" == *.mdx ]] \
|
||||||
|
|
@ -112,6 +120,7 @@ jobs:
|
||||||
echo "docs_only=$docs_only"
|
echo "docs_only=$docs_only"
|
||||||
echo "docs_changed=$docs_changed"
|
echo "docs_changed=$docs_changed"
|
||||||
echo "rust_changed=$rust_changed"
|
echo "rust_changed=$rust_changed"
|
||||||
|
echo "workflow_changed=$workflow_changed"
|
||||||
echo "base_sha=$BASE"
|
echo "base_sha=$BASE"
|
||||||
echo "docs_files<<EOF"
|
echo "docs_files<<EOF"
|
||||||
printf '%s\n' "${docs_files[@]}"
|
printf '%s\n' "${docs_files[@]}"
|
||||||
|
|
@ -260,10 +269,94 @@ jobs:
|
||||||
if: steps.collect_links.outputs.count == '0'
|
if: steps.collect_links.outputs.count == '0'
|
||||||
run: echo "No added links in changed docs lines. Link check skipped."
|
run: echo "No added links in changed docs lines. Link check skipped."
|
||||||
|
|
||||||
|
workflow-owner-approval:
|
||||||
|
name: Workflow Owner Approval
|
||||||
|
needs: [changes]
|
||||||
|
if: github.event_name == 'pull_request' && needs.changes.outputs.workflow_changed == 'true'
|
||||||
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
steps:
|
||||||
|
- name: Require owner approval for workflow file changes
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||||
|
env:
|
||||||
|
WORKFLOW_OWNER_LOGINS: ${{ vars.WORKFLOW_OWNER_LOGINS || 'theonlyhennygod,willsarg' }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
const prNumber = context.payload.pull_request?.number;
|
||||||
|
if (!prNumber) {
|
||||||
|
core.setFailed("Missing pull_request context.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerAllowlist = (process.env.WORKFLOW_OWNER_LOGINS || "")
|
||||||
|
.split(",")
|
||||||
|
.map((login) => login.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (ownerAllowlist.length === 0) {
|
||||||
|
core.setFailed("WORKFLOW_OWNER_LOGINS is empty. Set a repository variable or use a fallback value.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: prNumber,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowFiles = files
|
||||||
|
.map((file) => file.filename)
|
||||||
|
.filter((name) => name.startsWith(".github/workflows/"));
|
||||||
|
|
||||||
|
if (workflowFiles.length === 0) {
|
||||||
|
core.info("No workflow files changed in this PR.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Workflow files changed:\n- ${workflowFiles.join("\n- ")}`);
|
||||||
|
|
||||||
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: prNumber,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestReviewByUser = new Map();
|
||||||
|
for (const review of reviews) {
|
||||||
|
const login = review.user?.login;
|
||||||
|
if (!login) continue;
|
||||||
|
latestReviewByUser.set(login.toLowerCase(), review.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvedUsers = [...latestReviewByUser.entries()]
|
||||||
|
.filter(([, state]) => state === "APPROVED")
|
||||||
|
.map(([login]) => login);
|
||||||
|
|
||||||
|
if (approvedUsers.length === 0) {
|
||||||
|
core.setFailed("Workflow files changed but no approving review is present.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerApprover = approvedUsers.find((login) => ownerAllowlist.includes(login));
|
||||||
|
if (!ownerApprover) {
|
||||||
|
core.setFailed(
|
||||||
|
`Workflow files changed. Approvals found (${approvedUsers.join(", ")}), but none match WORKFLOW_OWNER_LOGINS.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Workflow owner approval present: @${ownerApprover}`);
|
||||||
|
|
||||||
ci-required:
|
ci-required:
|
||||||
name: CI Required Gate
|
name: CI Required Gate
|
||||||
if: always()
|
if: always()
|
||||||
needs: [changes, lint, lint-strict-delta, test, build, docs-only, non-rust, docs-quality]
|
needs: [changes, lint, lint-strict-delta, test, build, docs-only, non-rust, docs-quality, workflow-owner-approval]
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Enforce required status
|
- name: Enforce required status
|
||||||
|
|
@ -273,10 +366,17 @@ jobs:
|
||||||
|
|
||||||
docs_changed="${{ needs.changes.outputs.docs_changed }}"
|
docs_changed="${{ needs.changes.outputs.docs_changed }}"
|
||||||
rust_changed="${{ needs.changes.outputs.rust_changed }}"
|
rust_changed="${{ needs.changes.outputs.rust_changed }}"
|
||||||
|
workflow_changed="${{ needs.changes.outputs.workflow_changed }}"
|
||||||
docs_result="${{ needs.docs-quality.result }}"
|
docs_result="${{ needs.docs-quality.result }}"
|
||||||
|
workflow_owner_result="${{ needs.workflow-owner-approval.result }}"
|
||||||
|
|
||||||
if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then
|
if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then
|
||||||
echo "docs=${docs_result}"
|
echo "docs=${docs_result}"
|
||||||
|
echo "workflow_owner_approval=${workflow_owner_result}"
|
||||||
|
if [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then
|
||||||
|
echo "Workflow files changed but workflow owner approval gate did not pass."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
|
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
|
||||||
echo "Docs-only change touched markdown docs, but docs-quality did not pass."
|
echo "Docs-only change touched markdown docs, but docs-quality did not pass."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -288,6 +388,11 @@ jobs:
|
||||||
if [ "$rust_changed" != "true" ]; then
|
if [ "$rust_changed" != "true" ]; then
|
||||||
echo "rust_changed=false (non-rust fast path)"
|
echo "rust_changed=false (non-rust fast path)"
|
||||||
echo "docs=${docs_result}"
|
echo "docs=${docs_result}"
|
||||||
|
echo "workflow_owner_approval=${workflow_owner_result}"
|
||||||
|
if [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then
|
||||||
|
echo "Workflow files changed but workflow owner approval gate did not pass."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
|
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
|
||||||
echo "Docs changed but docs-quality did not pass."
|
echo "Docs changed but docs-quality did not pass."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -306,12 +411,18 @@ jobs:
|
||||||
echo "test=${test_result}"
|
echo "test=${test_result}"
|
||||||
echo "build=${build_result}"
|
echo "build=${build_result}"
|
||||||
echo "docs=${docs_result}"
|
echo "docs=${docs_result}"
|
||||||
|
echo "workflow_owner_approval=${workflow_owner_result}"
|
||||||
|
|
||||||
if [ "$lint_result" != "success" ] || [ "$lint_strict_delta_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then
|
if [ "$lint_result" != "success" ] || [ "$lint_strict_delta_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then
|
||||||
echo "Required CI jobs did not pass."
|
echo "Required CI jobs did not pass."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then
|
||||||
|
echo "Workflow files changed but workflow owner approval gate did not pass."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
|
if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then
|
||||||
echo "Docs changed but docs-quality did not pass."
|
echo "Docs changed but docs-quality did not pass."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
1
.github/workflows/workflow-sanity.yml
vendored
1
.github/workflows/workflow-sanity.yml
vendored
|
|
@ -7,7 +7,6 @@ on:
|
||||||
- ".github/*.yml"
|
- ".github/*.yml"
|
||||||
- ".github/*.yaml"
|
- ".github/*.yaml"
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/**"
|
- ".github/workflows/**"
|
||||||
- ".github/*.yml"
|
- ".github/*.yml"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
||||||
|
|
||||||
- `.github/workflows/ci.yml` (`CI`)
|
- `.github/workflows/ci.yml` (`CI`)
|
||||||
- Purpose: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate on changed Rust lines, `test`, release build smoke) + docs quality checks when docs change (`markdownlint` blocks only issues on changed lines; link check scans only links added on changed lines)
|
- Purpose: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate on changed Rust lines, `test`, release build smoke) + docs quality checks when docs change (`markdownlint` blocks only issues on changed lines; link check scans only links added on changed lines)
|
||||||
|
- Additional behavior: PRs that change `.github/workflows/**` require at least one approving review from a login in `WORKFLOW_OWNER_LOGINS` (repository variable fallback: `theonlyhennygod,willsarg`)
|
||||||
- Merge gate: `CI Required Gate`
|
- Merge gate: `CI Required Gate`
|
||||||
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)
|
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)
|
||||||
- Purpose: lint GitHub workflow files (`actionlint`, tab checks)
|
- Purpose: lint GitHub workflow files (`actionlint`, tab checks)
|
||||||
|
|
@ -39,7 +40,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
||||||
- Additional behavior: risk + size labels are auto-corrected on manual PR label edits (`labeled`/`unlabeled` events); apply `risk: manual` when maintainers intentionally override automated risk selection
|
- Additional behavior: risk + size labels are auto-corrected on manual PR label edits (`labeled`/`unlabeled` events); apply `risk: manual` when maintainers intentionally override automated risk selection
|
||||||
- High-risk heuristic paths: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**`
|
- High-risk heuristic paths: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**`
|
||||||
- Guardrail: maintainers can apply `risk: manual` to freeze automated risk recalculation
|
- Guardrail: maintainers can apply `risk: manual` to freeze automated risk recalculation
|
||||||
- `.github/workflows/auto-response.yml` (`Auto Response`)
|
- `.github/workflows/auto-response.yml` (`PR Auto Responder`)
|
||||||
- Purpose: first-time contributor onboarding + label-driven response routing (`r:support`, `r:needs-repro`, etc.)
|
- Purpose: first-time contributor onboarding + label-driven response routing (`r:support`, `r:needs-repro`, etc.)
|
||||||
- Additional behavior: applies contributor tiers on issues by merged PR count (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50), matching PR tier thresholds exactly
|
- Additional behavior: applies contributor tiers on issues by merged PR count (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50), matching PR tier thresholds exactly
|
||||||
- Additional behavior: contributor-tier labels are treated as automation-managed (manual add/remove on PR/issue is auto-corrected)
|
- Additional behavior: contributor-tier labels are treated as automation-managed (manual add/remove on PR/issue is auto-corrected)
|
||||||
|
|
@ -59,7 +60,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
||||||
- `Security Audit`: push to `main`, PRs to `main`, weekly schedule
|
- `Security Audit`: push to `main`, PRs to `main`, weekly schedule
|
||||||
- `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change
|
- `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change
|
||||||
- `PR Labeler`: `pull_request_target` lifecycle events
|
- `PR Labeler`: `pull_request_target` lifecycle events
|
||||||
- `Auto Response`: issue opened/labeled, `pull_request_target` opened/labeled
|
- `PR Auto Responder`: issue opened/labeled, `pull_request_target` opened/labeled
|
||||||
- `Stale`: daily schedule, manual dispatch
|
- `Stale`: daily schedule, manual dispatch
|
||||||
- `Dependabot`: weekly dependency maintenance windows
|
- `Dependabot`: weekly dependency maintenance windows
|
||||||
- `PR Hygiene`: every 12 hours schedule, manual dispatch
|
- `PR Hygiene`: every 12 hours schedule, manual dispatch
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ Maintain these branch protection rules on `main`:
|
||||||
- Require check `CI Required Gate`.
|
- Require check `CI Required Gate`.
|
||||||
- Require pull request reviews before merge.
|
- Require pull request reviews before merge.
|
||||||
- Require CODEOWNERS review for protected paths.
|
- Require CODEOWNERS review for protected paths.
|
||||||
|
- For `.github/workflows/**`, require owner approval via `CI Required Gate` (`WORKFLOW_OWNER_LOGINS`) and keep branch/ruleset bypass limited to org owners.
|
||||||
- Dismiss stale approvals when new commits are pushed.
|
- Dismiss stale approvals when new commits are pushed.
|
||||||
- Restrict force-push on protected branches.
|
- Restrict force-push on protected branches.
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ Maintain these branch protection rules on `main`:
|
||||||
- Maintainers can run `PR Labeler` manually (`workflow_dispatch`) in `audit` mode for drift visibility or `repair` mode to normalize managed label metadata repository-wide.
|
- Maintainers can run `PR Labeler` manually (`workflow_dispatch`) in `audit` mode for drift visibility or `repair` mode to normalize managed label metadata repository-wide.
|
||||||
- Hovering a label in GitHub shows its auto-managed description (rule/threshold summary).
|
- Hovering a label in GitHub shows its auto-managed description (rule/threshold summary).
|
||||||
- Managed label colors are arranged by display order to create a smooth gradient across long label rows.
|
- Managed label colors are arranged by display order to create a smooth gradient across long label rows.
|
||||||
- `Auto Response` posts first-time guidance, handles label-driven routing for low-signal items, and auto-applies issue contributor tiers using the same thresholds as `PR Labeler` (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50).
|
- `PR Auto Responder` posts first-time guidance, handles label-driven routing for low-signal items, and auto-applies issue contributor tiers using the same thresholds as `PR Labeler` (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50).
|
||||||
|
|
||||||
### Step B: Validation
|
### Step B: Validation
|
||||||
|
|
||||||
|
|
@ -159,7 +160,7 @@ Issue triage discipline:
|
||||||
|
|
||||||
Automation side-effect guards:
|
Automation side-effect guards:
|
||||||
|
|
||||||
- `Auto Response` deduplicates label-based comments to avoid spam.
|
- `PR Auto Responder` deduplicates label-based comments to avoid spam.
|
||||||
- Automated close routes are limited to issues, not PRs.
|
- Automated close routes are limited to issues, not PRs.
|
||||||
- Maintainers can freeze automated risk recalculation with `risk: manual` when context demands human override.
|
- Maintainers can freeze automated risk recalculation with `risk: manual` when context demands human override.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue