diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3db2a57 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Project requirements and documentation + url: https://github.com/mongo/ms-agent-framework-mongodb#readme + about: Review project scope, requirements, and documentation before opening an issue. + - name: Report a security vulnerability privately + url: https://github.com/mongo/ms-agent-framework-mongodb/security/advisories/new + about: Do not disclose credentials, private data, or security vulnerabilities in a public issue. diff --git a/.github/ISSUE_TEMPLATE/dotnet-issue.yml b/.github/ISSUE_TEMPLATE/dotnet-issue.yml new file mode 100644 index 0000000..68e7cef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dotnet-issue.yml @@ -0,0 +1,97 @@ +name: .NET bug report +description: Report a bug in the .NET MongoDB provider +title: "[.NET] Bug: " +labels: [".NET"] +type: bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting a problem. Remove credentials, connection strings, user content, embeddings, and retrieved documents before submitting. + + - type: textarea + id: problem + attributes: + label: Problem + description: Describe the observed and expected behavior. + placeholder: | + - What happened? + - What did you expect to happen? + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Provide the smallest code sample and steps that reproduce the issue. + placeholder: | + // Redact secrets and private data. + // Include provider construction and the failing operation. + render: csharp + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Redacted errors and logs + description: Include the complete exception chain and relevant logs after removing sensitive values and content. + render: shell + validations: + required: false + + - type: input + id: provider-version + attributes: + label: Provider version + placeholder: "MongoDB.AgentFramework 1.0.0" + validations: + required: true + + - type: input + id: dependency-versions + attributes: + label: Runtime and dependency versions + description: Include the .NET runtime, Microsoft Agent Framework, and MongoDB.Driver versions. + placeholder: ".NET 8.0; Microsoft.Agents.AI 1.x; MongoDB.Driver 3.x" + validations: + required: true + + - type: dropdown + id: feature + attributes: + label: Feature area + options: + - Memory + - Chat History + - RAG - vector + - RAG - full text + - RAG - hybrid + - Index management + - Session Store or Workflow Checkpoint Store + - Packaging, samples, or documentation + - Other + validations: + required: true + + - type: textarea + id: mongodb-environment + attributes: + label: MongoDB environment + description: Include deployment type, server version, driver-visible topology, search mode, and index status. Do not include hostnames or credentials. + placeholder: | + Deployment: Atlas / Enterprise / Community + MongoDB version: + Search mode: ANN / ENN / full text / hybrid / not applicable + Index status and queryable state: + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Include cancellation, ownership, filtering, or regression details that may affect diagnosis. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..317914b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,90 @@ +name: Feature Request +description: Propose a feature for the MongoDB Agent Framework providers +title: "[Feature]: " +type: feature +body: + - type: markdown + attributes: + value: | + Check the project requirements, non-goals, and proposed ADRs before filing. Hard-to-reverse architecture or public schema changes require an ADR. + + - type: textarea + id: problem + attributes: + label: Problem and scenario + description: Describe the user problem and concrete scenario without prescribing an implementation. + placeholder: | + Who needs this? + What are they trying to accomplish? + What prevents them from doing it today? + validations: + required: true + + - type: dropdown + id: feature + attributes: + label: Feature area + options: + - Memory + - Chat History + - RAG + - Index management + - Session Store + - Workflow Checkpoint Store + - Shared infrastructure + - Packaging, samples, or documentation + - New or unclear boundary + validations: + required: true + + - type: textarea + id: proposed-behavior + attributes: + label: Proposed behavior or API + description: Show the expected observable behavior and an optional minimal API sketch. + placeholder: | + Describe inputs, outputs, failure behavior, and lifecycle effects. + validations: + required: false + + - type: dropdown + id: language + attributes: + label: Language/SDK + description: Which language/SDK does this feature apply to? + options: + - Both + - .NET + - Python + - Other / Not Applicable + default: 0 + validations: + required: true + + - type: textarea + id: security-lifecycle + attributes: + label: Security, data, and lifecycle impact + description: Explain tenant filtering, reads/writes, resource ownership, retention/deletion, logging/privacy, cancellation, and index effects. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives and compatibility + description: List alternatives, compatibility or migration impact, and any related requirement or ADR. + validations: + required: true + + - type: checkboxes + id: checks + attributes: + label: Scope checks + options: + - label: I reviewed the requirements and non-goals. + required: true + - label: I am not proposing model-controlled raw BSON, filters, operators, field paths, index names, or aggregation pipelines. + required: true + - label: I described whether equivalent Python and .NET behavior is expected. + required: true diff --git a/.github/ISSUE_TEMPLATE/python-issue.yml b/.github/ISSUE_TEMPLATE/python-issue.yml new file mode 100644 index 0000000..d1edd74 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/python-issue.yml @@ -0,0 +1,97 @@ +name: Python bug report +description: Report a bug in the Python MongoDB provider +title: "[Python] Bug: " +labels: ["Python"] +type: bug +body: + - type: markdown + attributes: + value: | + Thanks for reporting a problem. Remove credentials, connection strings, user content, embeddings, and retrieved documents before submitting. + + - type: textarea + id: problem + attributes: + label: Problem + description: Describe the observed and expected behavior. + placeholder: | + - What happened? + - What did you expect to happen? + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Provide the smallest code sample and steps that reproduce the issue. + placeholder: | + # Redact secrets and private data. + # Include provider construction and the failing operation. + render: python + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Redacted errors and logs + description: Include the complete exception chain and relevant logs after removing sensitive values and content. + render: shell + validations: + required: false + + - type: input + id: provider-version + attributes: + label: Provider version + placeholder: "agent-framework-mongodb 1.0.0" + validations: + required: true + + - type: input + id: dependency-versions + attributes: + label: Runtime and dependency versions + description: Include Python, agent-framework-core, and PyMongo versions. + placeholder: "Python 3.11; agent-framework-core 1.x; pymongo 4.x" + validations: + required: true + + - type: dropdown + id: feature + attributes: + label: Feature area + options: + - Memory + - Chat History + - RAG - vector + - RAG - full text + - RAG - hybrid + - Index management + - Session Store or Workflow Checkpoint Store + - Packaging, samples, or documentation + - Other + validations: + required: true + + - type: textarea + id: mongodb-environment + attributes: + label: MongoDB environment + description: Include deployment type, server version, driver-visible topology, search mode, and index status. Do not include hostnames or credentials. + placeholder: | + Deployment: Atlas / Enterprise / Community + MongoDB version: + Search mode: ANN / ENN / full text / hybrid / not applicable + Index status and queryable state: + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Include cancellation, ownership, filtering, or regression details that may affect diagnosis. + validations: + required: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..364685e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,126 @@ +# GitHub Copilot Instructions + +This repository provides independently released MongoDB integrations for Microsoft Agent Framework in Python and .NET. Treat [docs/spec/README.md](../docs/spec/README.md) and its linked documents as the canonical implementation specifications, [docs/spec/implementation-map.md](../docs/spec/implementation-map.md) as the required branch and commit order, and `docs/decisions/` as the record of approved architectural choices. Canonical identities are repository `mongo/ms-agent-framework-mongodb`, Python distribution `agent-framework-mongodb` with import root `agent_framework_mongodb`, and .NET package and namespace `MongoDB.AgentFramework`. + +## Requirement Language + +- `MUST`, `MUST NOT`, and `REQUIRED` requirements are release blockers. +- `SHOULD` and `SHOULD NOT` requirements require an ADR to override. +- Do not silently weaken a requirement. Surface conflicts and update the relevant proposed ADR before implementation. + +## Product Boundaries + +- Keep Memory, Chat History, RAG, Session Store, and Workflow Checkpoint Store as separate public modules and provider types. +- Memory is semantic conversation recall. Chat History is exact ordered replay. RAG is read-only knowledge retrieval. Session Store persists complete sessions. Workflow Checkpoint Store persists resumable workflow state and lineage. +- Feature modules may depend on shared internal MongoDB mechanics. Shared internals must not depend on feature modules, and feature modules must not call each other. +- Production RAG ingestion, arbitrary MongoDB agent tools, model-generated BSON/pipelines, fact extraction, and graph behavior are out of scope. + +## Framework Integration + +- Depend only on current public Microsoft Agent Framework contracts. Do not change framework core types for MongoDB-specific behavior. +- Preserve framework source attribution, message filtering, session state, cancellation, and serialization conventions. +- Use `ContextProvider` and `HistoryProvider` in Python and the corresponding public context/history abstractions in .NET. +- Do not subclass sealed .NET types. Compose `TextSearchProvider` only when compatibility tests prove cancellation, citations, score/metadata preservation, and on-demand behavior; otherwise implement a dedicated adapter. + +## MongoDB Safety + +- Build pipelines with driver builders or structured BSON, never string concatenation. +- Public filters must be typed and operator-limited. Translate the complete mandatory filter into every active retrieval branch or reject it. +- Apply tenant and authorization filters inside `$vectorSearch` and `$search` before candidate or result limiting. Application-side filtering is not an authorization boundary. +- Validate configured field paths, index names, limits, dimensions, and mode-specific options before contacting MongoDB. +- Never expose BSON, field names, operators, filters, index names, or pipelines as model-controlled tool arguments. +- Do not silently downgrade search modes or emulate unsupported MongoDB Search capabilities in application memory. +- Do not create or update indexes during provider construction, agent hooks, or direct search. Provisioning must be an explicit operation. +- Runtime RAG paths are read-only. Add tests that prove no insert, update, replace, upsert, or delete operation occurs. + +## Data And Lifecycle + +- Make resource ownership immutable at construction. Dispose only provider-created resources; never dispose injected clients, databases, collections, or embedding generators. +- Use stable scoped identifiers and idempotent writes for Memory and Chat History. +- Require an authorization scope for reads and deletion. Never treat a document ID alone as an authorization boundary, and reject unbounded empty deletion filters. +- Preserve exact framework-supported Chat History content in a versioned payload. Do not flatten messages to text. +- Treat stored schemas and index definitions as compatibility surfaces. Reject unknown versions with migration guidance. +- Do not claim Python/.NET physical collection interoperability until cross-language fixtures prove it. + +## Errors, Privacy, And Observability + +- Validate configuration, capabilities, indexes, filter translation, and mappings with stable integration-level error categories while preserving the driver exception as the cause. +- Direct search, storage, validation, and provisioning APIs fail to the caller. Only agent adapter boundaries may fail open for documented operational errors. +- Always propagate cancellation. Never catch cancellation as an ordinary operational failure. +- Rely on official driver retries first. Any provider retry must be transient-only, bounded by an overall deadline, and safe under idempotency rules. +- Use standard Python logging and `Microsoft.Extensions.Logging`. Do not log credentials, connection strings, embeddings, raw queries, message content, retrieved chunks, or user-bearing filters by default. + +## Cross-Language Implementation + +- Maintain equivalent observable behavior in Python and .NET while using language-idiomatic APIs. +- Cover shared defaults and behavior with language-neutral fixtures where possible. +- Record and document intentional language differences; do not force identical syntax, BSON casing, serializers, or package versions. +- Use PyMongo's asynchronous API for new Python code. Do not add Motor. +- Use typed MongoDB.Driver builders in .NET where supported and structured BSON only for unsupported stages or options. + +## Engineering Workflow + +- Before making changes, identify the specific feature, language, search mode, specification sections, and issue or task being implemented. +- Inspect `git branch --show-current`, `git status --short`, and the branch's existing scope before editing. +- Never implement directly on `main`. If the branch is `main`, detached, or belongs to another feature, stop before editing and recommend a branch using `/-` from the appropriate base. +- State the current branch, detected feature scope, reason for mismatch, recommended branch name, and intended base. Do not create or switch branches without explicit user approval. +- Treat unrelated uncommitted changes as user work. Do not move, stash, reset, commit, or carry them to another branch without explicit approval. +- Implement the smallest vertical slice that proves behavior through a public interface. +- Add or update tests with each behavior change. Unit tests must not require network access; credentialed integration tests must skip cleanly when credentials are absent. +- Keep external-test resources uniquely prefixed and ensure cleanup can target only test resources. +- Run the narrowest relevant lint, type, build, and test checks first, then the language quality gate for the affected package. +- Build and smoke test publishable wheel, sdist, and NuGet artifacts rather than relying only on project references. +- Do not mix prototype extraction, public renaming, new RAG behavior, and upstream cleanup in one change. + +## Commit Discipline + +Follow [CONTRIBUTING.md](../CONTRIBUTING.md) for specification validation, branch workflow, commit sequencing, commit units, message format, pre-commit checks, and history safety. + +- Create a commit only when the user explicitly requests it. +- Confirm the branch still matches the feature before staging or committing. Never mix changes from another branch scope into the commit. +- Before coding, map the change to the canonical specifications and implementation-map row, then review its linked ADRs. The specifications authorize the mapped implementation; proposed ADRs record rationale but do not authorize deviations from the specifications. +- Plan the commit series before broad implementation work. Each commit must be a logically separate changeset that leaves the branch buildable, testable, reviewable, and safe to revert independently. +- A commit contains one coherent feature slice, fix, refactor, or infrastructure change. If one subject cannot accurately describe the staged diff, split it. +- Never combine separate product features, RAG modes, language implementations, mechanical refactors, dependency updates, or unrelated cleanup in one commit. +- Keep commits independently buildable, testable, reviewable, and bisectable. Include focused tests and directly associated documentation for the same behavior. +- Order commits by dependency: accepted specification/ADR, shared contract, one language implementation, equivalent language implementation, samples/integration coverage, then packaging and release automation. +- Use `(): ` with an allowed type and narrow project scope. Keep the subject at 72 characters or fewer. +- Every non-trivial implementation, fix, refactor, performance, security, public API, schema, index, compatibility, or release commit requires a detailed body after a blank line. Explain why the change is needed, the relevant prior behavior, the chosen implementation and important trade-offs, and the validation performed. Do not merely restate the subject or list changed files. +- Use commit footers for issue references, acknowledgments, and `BREAKING CHANGE:` migration details. Do not hide breaking behavior only in the body. +- Do not create placeholder, checkpoint, `WIP`, `fixup!`, or vague follow-up commits in the final series. Fold corrections into the owning commit only through an explicitly approved history-cleanup operation. +- Review the staged diff and run `git diff --cached --check`, the narrowest behavior validation, and the affected quality gate before committing. +- Never commit secrets, local configuration, debug code, unrelated user changes, or knowingly failing tests. +- Do not create, switch, rename, or delete branches, or amend, rebase, squash, force-push, or otherwise rewrite history without explicit user approval. + +## Developer Documentation + +Developer documentation is a required part of implementation, not a release follow-up. Maintain detailed code-level documentation under `docs/development/`, organized by feature and language, and link it from a local index. Update it in the same commit as the behavior it describes. + +- Treat specifications as normative requirements, ADRs as decision rationale, and developer documentation as the maintained explanation of the implemented system. Developer documentation must supplement rather than copy the specifications or ADRs, link to both, and identify the implementation-map slice it realizes. +- Document architecture and design at the level needed to safely modify the code: module responsibilities, ownership boundaries, dependencies, public framework integration points, control and data flow, and why the implementation uses its chosen abstractions. +- Document implementation details that are not obvious from public APIs: algorithms, state transitions, invariants, concurrency and idempotency behavior, serialization and mapping rules, validation order, error translation, cancellation, retries and deadlines, and resource ownership and disposal. +- Document public and extension surfaces with exact symbols and paths: constructors, options, defaults, return types, exceptions, configuration and environment variables, capability gates, privileges, and concise runnable examples. +- For stored or queried data, document BSON schemas, field semantics, identifiers and scopes, versioning, indexes, filter placement, authorization boundaries, migrations, and compatibility implications. Include representative structured documents or pipelines when they clarify behavior, but never include secrets or production data. +- Document observability and operations: emitted logs or traces, required redaction, setup and provisioning, expected failure modes, troubleshooting steps, performance-sensitive choices, known limitations, and externally validated prerequisites. +- Document the verification strategy: focused unit and contract tests, integration fixtures, security assertions, package or sample checks, and the commands that were actually validated. Never claim a command, compatibility range, or deployment behavior that was not verified. +- Record intentional Python/.NET differences and the equivalent observable behavior that preserves parity. Do not force identical internal structure where language conventions differ. +- Prefer precise links to source files, symbols, tests, and existing documents over duplicated prose. Use diagrams or tables when they communicate lifecycle, state, schema, or dependency relationships more clearly than paragraphs. +- Keep documentation current and factual. Remove or revise stale content in the owning code change; do not document speculative behavior as implemented. If code, developer documentation, a specification, and an ADR conflict, stop and resolve the authoritative specification or decision before proceeding. +- Docstrings, XML documentation, comments, examples, and API references complement developer documentation but do not replace the feature-level design and implementation explanation. + +## Naming + +- Use `MongoDB Search` and `MongoDB Vector Search` unless a requirement is specifically Atlas-only. +- Use the canonical public names and package identities in the requirements until an accepted ADR changes them. +- Never commit credentials or connection strings. Samples must use documented environment variables and provide clear setup failures. + +## Architectural Decision Records (ADRs) + +ADRs in `docs/decisions/` capture hard-to-reverse decisions and their rationale. Architectural changes, requirement overrides, public schema changes, and package/release policy changes require an ADR. + +- New ADRs start as `proposed` and identify deciders, consulted partners, and informed parties. +- Use `adr-template.md` when alternatives and trade-offs matter. +- Use `adr-short-template.md` only for a narrow decision with no material alternative analysis. +- Never treat a proposed ADR as approved. Approval is represented by PR approval and an `accepted` status update. + +See [docs/decisions/README.md](../docs/decisions/README.md) for the process and current decision index. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f2b024a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,75 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/dotnet" + schedule: + interval: "weekly" + day: "thursday" + time: "08:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 10 + groups: + agent-framework: + patterns: + - "Microsoft.Agents.*" + - "Microsoft.Extensions.AI*" + - "Microsoft.Extensions.VectorData*" + mongodb: + patterns: + - "MongoDB.*" + ignore: + - dependency-name: "System.*" + update-types: ["version-update:semver-major"] + - dependency-name: "Microsoft.Extensions.*" + update-types: ["version-update:semver-major"] + - dependency-name: "Microsoft.Bcl.*" + update-types: ["version-update:semver-major"] + labels: + - ".NET" + - "dependencies" + commit-message: + prefix: "deps(.NET)" + + - package-ecosystem: "pip" + directory: "/python" + schedule: + interval: "weekly" + day: "thursday" + time: "08:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 10 + groups: + agent-framework: + patterns: + - "agent-framework-*" + mongodb: + patterns: + - "pymongo" + python-quality: + dependency-type: "development" + patterns: + - "*" + labels: + - "Python" + - "dependencies" + commit-message: + prefix: "deps(Python)" + + - package-ecosystem: "github-actions" + directories: + - "/" + - "/.github/actions/*" + schedule: + interval: "weekly" + day: "sunday" + time: "08:00" + timezone: "Etc/UTC" + groups: + github-actions: + patterns: + - "*" + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "deps(actions)" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1999e1c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,67 @@ +# Pull Request + +## Problem + + + +Fixes # + +## Approach + + + +## Scope + +- Feature area: +- Language: +- Search mode: +- Source branch: + +## Requirements And Decisions + + + +- Requirements: +- ADRs: +- Public API or stored-schema impact: +- Compatibility or migration impact: + +## Security, Privacy, And Lifecycle + + + +## Validation + + + +- [ ] Added or updated focused unit tests. +- [ ] Added or updated language-neutral contract fixtures when behavior is shared. +- [ ] Ran the affected Python and/or .NET quality gate. +- [ ] Built and smoke-tested affected package artifacts. +- [ ] Ran real MongoDB integration tests, or documented why they were not applicable/available. +- [ ] Verified cancellation and provider/caller resource ownership where applicable. +- [ ] Verified mandatory filters execute in MongoDB before limiting in every retrieval branch. +- [ ] Verified RAG runtime paths remain read-only where applicable. +- [ ] Updated public documentation, compatibility matrices, samples, and migration notes as needed. + +## Commit Quality + +- [ ] The source branch is short-lived, follows `/-`, and contains one feature or maintenance objective. +- [ ] The branch was created from the correct base and does not contain unrelated work. +- [ ] Each commit contains one feature slice, fix, refactor, or infrastructure change. +- [ ] Separate product features, RAG modes, and Python/.NET implementations are not combined in one commit. +- [ ] Commits follow the dependency and delivery sequence in the requirements. +- [ ] Every commit is independently buildable, testable, reviewable, and bisectable. +- [ ] Commit messages follow `(): ` and describe the complete staged change. +- [ ] Specification/ADR changes precede dependent implementation commits. +- [ ] Fixup, WIP, formatting-only noise, and unrelated changes are absent from the final history. + +## Review Checklist + +- [ ] The change uses only public Agent Framework contracts. +- [ ] The change preserves Memory, Chat History, RAG, Session Store, and Workflow Checkpoint boundaries. +- [ ] Pipelines use driver builders or structured BSON without string interpolation. +- [ ] No secrets, user content, embeddings, raw queries, or retrieved chunks are logged by default. +- [ ] Index provisioning is explicit and never runs in provider hooks or direct search. +- [ ] Python and .NET behavior is equivalent, or the intentional difference is documented. +- [ ] This is not a breaking change. If it is, explain the versioning and migration plan above and apply the `breaking change` label. diff --git a/.github/scripts/secret-scan.sh b/.github/scripts/secret-scan.sh new file mode 100644 index 0000000..dfc8826 --- /dev/null +++ b/.github/scripts/secret-scan.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Local, pattern-based repository secret scan. +# +# This intentionally does NOT download or execute any third-party binary (no gitleaks CLI, no +# other scanner). It is a dependency-free `git grep` gate satisfying the "secret scan available +# locally" requirement without the supply-chain and licensing questions that come with fetching a +# release artifact. It can be run identically in CI (see .github/workflows/dotnet-security.yml) +# and on a developer machine with only `git` and `bash` installed. +# +# Known limitations (documented per policy, not fixed here): +# - Scans only the files tracked by git at the current checkout (`git grep` over the working +# tree/index), not full git history. A secret that was committed and later removed will not +# be caught by this script; use a history-aware scanner for that guarantee if ever required. +# - Pattern-based only: it recognizes known credential/token shapes (cloud provider keys, private +# key headers, connection strings with embedded credentials) plus a generic +# "name-looks-like-a-secret and is assigned a literal value" heuristic. It has no entropy +# analysis and will miss secrets that do not match one of these shapes. +# - The generic heuristic explicitly excludes this repository's own `SENTINEL-SECRET-...` test +# fixtures (see dotnet/tests/MongoDB.AgentFramework.Tests/Observability/), which are +# intentional, non-sensitive plaintext markers used by tests to prove telemetry redaction, not +# real secrets. +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" + +status=0 +tracked_count=$(git ls-files | wc -l | tr -d ' ') +echo "Scanning ${tracked_count} tracked files for common secret patterns..." + +report() { + local label="$1" + shift + echo "=== ${label} ===" + if git grep -n -I "$@" -- . 2>/dev/null; then + status=1 + fi +} + +report "MongoDB connection strings with embedded credentials" \ + -E 'mongodb(\+srv)?://[^:@/[:space:]]+:[^@/[:space:]]+@' + +report "AWS access key IDs" \ + -E 'AKIA[0-9A-Z]{16}' + +report "GitHub tokens" \ + -E '(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}' + +report "Slack tokens" \ + -E 'xox[baprs]-[A-Za-z0-9-]{10,}' + +report "Google API keys" \ + -E 'AIza[0-9A-Za-z_-]{35}' + +report "Private key material" \ + -E 'BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY' + +echo "=== Hardcoded credential-like assignments ===" +credential_assignment_dq='(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\s*[:=]\s*"[^"[:space:]]{8,}"' +credential_assignment_sq="(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\\s*[:=]\\s*'[^'[:space:]]{8,}'" +credential_hits="" +if matches=$(git grep -n -I -i -P "$credential_assignment_dq" -- . 2>/dev/null); then + credential_hits="${credential_hits}${matches}"$'\n' +fi +if matches=$(git grep -n -I -i -P "$credential_assignment_sq" -- . 2>/dev/null); then + credential_hits="${credential_hits}${matches}"$'\n' +fi +credential_hits=$(printf '%s' "$credential_hits" | grep -v -F 'SENTINEL-SECRET-' || true) +if [ -n "$credential_hits" ]; then + echo "$credential_hits" + status=1 +fi + +if [ "$status" -ne 0 ]; then + echo "Potential secret(s) found by pattern scan. Review the matches above." >&2 + exit 1 +fi + +echo "No secret patterns found." diff --git a/.github/scripts/secret-scan.test.sh b/.github/scripts/secret-scan.test.sh new file mode 100644 index 0000000..2c6f9bd --- /dev/null +++ b/.github/scripts/secret-scan.test.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Lightweight self-test for secret-scan.sh's own detection logic. +# +# secret-scan.sh's actual job is to scan *this* repository, so the only way to prove it still detects real +# secrets and still respects its documented SENTINEL-SECRET- exclusion (without ever committing a real-looking +# secret into this repository's own history) is to run it against small, disposable scratch git repositories +# created for exactly this test. No third-party tooling is used -- only `bash` and `git`, matching +# secret-scan.sh's own dependency-free design. Run locally exactly as CI does: +# bash .github/scripts/secret-scan.test.sh +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +scan_script="$script_dir/secret-scan.sh" + +scratch=$(mktemp -d) +cleanup() { rm -rf "$scratch"; } +trap cleanup EXIT + +make_scratch_repo() { + local dir="$1" + mkdir -p "$dir" + git init -q "$dir" + git -C "$dir" config user.email "test@example.invalid" + git -C "$dir" config user.name "secret-scan self-test" +} + +commit_all() { + local dir="$1" + git -C "$dir" add -A + git -C "$dir" commit -q -m "scratch fixture" +} + +# secret-scan.sh always resolves its target repository via `git rev-parse --show-toplevel` from the current +# directory, so running it from inside each scratch repo scans only that scratch repo, never this real one. +run_scan_in() { + local dir="$1" + (cd "$dir" && bash "$scan_script") +} + +failures=0 + +assert_fails() { + local label="$1" dir="$2" + if run_scan_in "$dir" > /dev/null 2>&1; then + echo "FAIL: expected secret-scan.sh to detect a secret in '${label}' fixture, but it exited 0." + failures=$((failures + 1)) + else + echo "PASS: secret-scan.sh detected the '${label}' fixture as expected." + fi +} + +assert_passes() { + local label="$1" dir="$2" + if run_scan_in "$dir" > /dev/null 2>&1; then + echo "PASS: secret-scan.sh reported no findings for the '${label}' fixture as expected." + else + echo "FAIL: expected secret-scan.sh to report no findings for '${label}' fixture, but it exited non-zero." + failures=$((failures + 1)) + fi +} + +# Case 1: a synthetic AWS access key ID must be detected. Built from two halves at runtime (never as one +# contiguous literal in this file) so this self-test script itself never contains a string secret-scan.sh's +# own AWS-key pattern would match -- it must only appear in the disposable scratch fixture it writes below. +positive_dir="$scratch/positive" +make_scratch_repo "$positive_dir" +aws_key_prefix="AKIA" +aws_key_rest="ABCDEFGHIJKLMNOP" +printf 'const string Key = "%s%s";\n' "$aws_key_prefix" "$aws_key_rest" > "$positive_dir/secret.cs" +commit_all "$positive_dir" +assert_fails "AWS access key" "$positive_dir" + +# Case 2: the documented SENTINEL-SECRET- test-fixture exclusion must still be honored. +sentinel_dir="$scratch/sentinel" +make_scratch_repo "$sentinel_dir" +printf 'private const string Secret = "SENTINEL-SECRET-0123456789abcdef";\n' > "$sentinel_dir/fixture.cs" +commit_all "$sentinel_dir" +assert_passes "SENTINEL-SECRET- exclusion" "$sentinel_dir" + +# Case 3: a clean tree with no secret-shaped content must report no findings. +clean_dir="$scratch/clean" +make_scratch_repo "$clean_dir" +printf 'public sealed class Nothing { }\n' > "$clean_dir/clean.cs" +commit_all "$clean_dir" +assert_passes "clean tree" "$clean_dir" + +if [ "$failures" -ne 0 ]; then + echo "secret-scan.sh self-test failed (${failures} case(s))." >&2 + exit 1 +fi + +echo "secret-scan.sh self-test passed." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..59a5f0b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: CodeQL + +on: + pull_request: + paths: + - "python/**" + - ".github/workflows/codeql.yml" + push: + branches: + - "main" + - "feature/python-implementation" + - "build/python-packaging-release" + paths: + - "python/**" + - ".github/workflows/codeql.yml" + schedule: + - cron: "23 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-python-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout v4.4.0 + with: + persist-credentials: false + - uses: github/codeql-action/init@c4dd10e44af883a891fe31ced449bcb4a6728b9b # github/codeql-action v3.37.6 + with: + languages: python + - uses: github/codeql-action/analyze@c4dd10e44af883a891fe31ced449bcb4a6728b9b # github/codeql-action v3.37.6 + with: + category: "/language:python" diff --git a/.github/workflows/credential-scan.yml b/.github/workflows/credential-scan.yml new file mode 100644 index 0000000..64382af --- /dev/null +++ b/.github/workflows/credential-scan.yml @@ -0,0 +1,26 @@ +name: Credential pattern scan + +on: + pull_request: + push: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: credential-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python v5 + with: + python-version: "3.10" + - run: python scripts/scan_credentials.py diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..1b325c0 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,17 @@ +name: Dependency review + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout v4.4.0 + with: + persist-credentials: false + - uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # actions/dependency-review-action v4 diff --git a/.github/workflows/dotnet-agent-framework-compatibility.yml b/.github/workflows/dotnet-agent-framework-compatibility.yml new file mode 100644 index 0000000..796aff8 --- /dev/null +++ b/.github/workflows/dotnet-agent-framework-compatibility.yml @@ -0,0 +1,64 @@ +name: .NET Agent Framework upstream compatibility + +on: + workflow_dispatch: + inputs: + exact_version: + description: Optional exact common listed version to test in addition to latest stable and preview + required: false + type: string + schedule: + - cron: "17 6 * * 1" + +permissions: + contents: read + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.resolve.outputs.matrix }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: 10.0.x + - name: Resolve latest stable, latest preview, and optional exact version + id: resolve + shell: pwsh + working-directory: dotnet + env: + EXACT_VERSION: ${{ inputs.exact_version }} + run: ./scripts/resolve-agent-framework-versions.ps1 -Mode AllDispatch -ExactVersion $env:EXACT_VERSION + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-framework-version-resolution + path: dotnet/artifacts/agent-framework-version-resolution/* + if-no-files-found: error + + compatibility: + needs: resolve + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.resolve.outputs.matrix) }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + - name: Restore, test, build, pack, and consumer-smoke exact version + shell: pwsh + working-directory: dotnet + run: ./scripts/verify-agent-framework-compatibility.ps1 -Configuration Release -Versions "${{ matrix.version }}" + - name: Upload TRX, JSON, and Markdown reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-framework-compat-${{ matrix.version }} + path: dotnet/artifacts/agent-framework-compat-results/* + if-no-files-found: error diff --git a/.github/workflows/dotnet-integration.yml b/.github/workflows/dotnet-integration.yml new file mode 100644 index 0000000..357f6d0 --- /dev/null +++ b/.github/workflows/dotnet-integration.yml @@ -0,0 +1,121 @@ +name: .NET credentialed integration tests + +# Implements docs/spec/quality-release.md's `integration-memory`, `integration-history`, `integration-rag-vector`, +# `integration-rag-search`, `integration-rag-hybrid`, and `integration-persistence` CI jobs for .NET (plus +# `integration-index-management`, an additional xunit Trait Category this repository's own integration tests +# already use for Vector Search/Search index provisioning coverage). Each targets a real, Search-capable MongoDB +# Atlas deployment reachable through the `MONGODB_URI`/`MONGODB_DATABASE` secrets configured on the +# `dotnet-integration` GitHub Environment (an owner-configured protection rule, not created by this workflow). +# +# Fork/credential safety: per docs/spec/quality-release.md, "credentialed integration jobs ... must never execute +# untrusted fork code with secrets." This workflow intentionally does NOT trigger on `pull_request` at all: it +# runs only on push to main, on a schedule (to catch upstream Agent Framework/driver/MongoDB service changes per +# the same spec section), and on manual workflow_dispatch -- all of which use this repository's own trusted +# checkout, never a fork-supplied ref, and the `dotnet-integration` environment's own required-reviewer protection +# (if configured by the repository owner) gates secret exposure further still. +# +# No live MongoDB deployment or environment secrets exist in the validation environment this workflow was +# authored in. Each job first runs a preflight step (below) that fails loudly and actionably if the +# `dotnet-integration` environment's required secrets are absent -- job-level `if:` cannot reference `secrets.*`, +# so this cannot be done by conditioning the job out of existence, and skipping the tests silently would report a +# false-green "0 tests, all passed". Once credentials are configured, each job also asserts (via the produced TRX +# file's ``, not console output or exit code alone) that its filtered Category actually +# executed at least one test, since an unrestored project or a mistyped/impossible `--filter` can both otherwise +# "succeed" having run zero tests. +# +# Supply-chain note: actions pinned exactly as in dotnet-security.yml/dotnet-quality.yml. +on: + push: + branches: + - main + - build/dotnet-packaging-release + schedule: + - cron: "23 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + dotnet-integration: + name: ${{ matrix.job }} + runs-on: ubuntu-latest + environment: dotnet-integration + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - job: integration-memory + category: integration-memory + - job: integration-history + category: integration-history + - job: integration-rag-vector + category: integration-rag + - job: integration-rag-search + category: integration-rag-search + - job: integration-rag-hybrid + category: integration-rag-hybrid + - job: integration-index-management + category: integration-index-management + - job: integration-persistence + category: integration-persistence + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + # Job-level `if:` cannot reference `secrets.*` (GitHub Actions does not expose secrets to job/step `if:` + # expressions), so an absent MONGODB_URI/MONGODB_DATABASE on the `dotnet-integration` environment cannot be + # used to skip this job out of existence -- it would otherwise report a silently-empty "success" with zero + # tests executed. Instead this preflight *step* fails loudly and actionably before restore/test even runs, + # so a misconfigured environment is an unmissable red job, never a quietly-green no-op. + - name: Verify dotnet-integration environment credentials are configured + env: + MONGODB_URI: ${{ secrets.MONGODB_URI }} + MONGODB_DATABASE: ${{ secrets.MONGODB_DATABASE }} + shell: pwsh + run: | + $missing = @() + if ([string]::IsNullOrWhiteSpace($env:MONGODB_URI)) { $missing += "MONGODB_URI" } + if ([string]::IsNullOrWhiteSpace($env:MONGODB_DATABASE)) { $missing += "MONGODB_DATABASE" } + if ($missing.Count -gt 0) { + Write-Host "::error::The 'dotnet-integration' GitHub Environment is missing required secret(s): $($missing -join ', '). Configure them on the environment (Settings > Environments > dotnet-integration) before this workflow can run credentialed integration tests. This job intentionally fails here instead of silently skipping, so a misconfigured environment is never reported as a false-green '0 tests, all passed'." + exit 1 + } + Write-Host "dotnet-integration environment credentials are present (MONGODB_URI, MONGODB_DATABASE)." + + - name: Restore + working-directory: dotnet + run: dotnet restore tests/MongoDB.AgentFramework.Tests/MongoDB.AgentFramework.Tests.csproj + + - name: Run ${{ matrix.job }} (Category=${{ matrix.category }}) + working-directory: dotnet + env: + MONGODB_URI: ${{ secrets.MONGODB_URI }} + MONGODB_DATABASE: ${{ secrets.MONGODB_DATABASE }} + run: | + dotnet test tests/MongoDB.AgentFramework.Tests/MongoDB.AgentFramework.Tests.csproj \ + --configuration Release \ + --no-restore \ + --filter "Category=${{ matrix.category }}" \ + --logger "console;verbosity=normal" \ + --logger "trx;LogFileName=${{ matrix.job }}.trx" \ + --results-directory artifacts/integration-test-results + + # A passing exit code above does not prove any test ran: an unrestored project or an impossible --filter + # both "succeed" having executed zero tests. This step parses the TRX's and + # fails the job unless it is strictly greater than zero -- so a credentialed job whose category's tests + # all skip themselves (e.g. because the preflight step above was somehow bypassed, or the category name + # was mistyped and matched nothing) fails here rather than reporting a false-green "0 executed, 0 failed". + - name: Verify at least one ${{ matrix.job }} test actually executed + shell: pwsh + run: pwsh dotnet/scripts/assert-trx-executed.ps1 -TrxPath "dotnet/artifacts/integration-test-results/${{ matrix.job }}.trx" -Label "${{ matrix.job }}" diff --git a/.github/workflows/dotnet-quality.yml b/.github/workflows/dotnet-quality.yml new file mode 100644 index 0000000..b314e31 --- /dev/null +++ b/.github/workflows/dotnet-quality.yml @@ -0,0 +1,250 @@ +name: .NET build, test, and package quality + +# Implements the "dotnet-quality" job from docs/spec/quality-release.md's CI workflow topology: restore, build +# every target framework with warnings as errors, formatting/analyzer validation, unit tests, pack, and a NuGet +# install/runtime smoke test from the produced package (not a project reference). Credentialed Search-capable +# deployment integration tests are covered separately by dotnet-integration.yml, gated behind an approved +# environment/secrets per that same spec section; this workflow only ever runs credential-free checks, so it is +# safe to run on every pull request, including from forks. +# +# TFM matrix note: every .NET target framework this package ships (net8.0/net9.0/net10.0) already builds from a +# single multi-targeted `dotnet build`/`dotnet pack` invocation (see src/MongoDB.AgentFramework/MongoDB.AgentFramework.csproj's +# ), so a second matrix dimension over TFMs would not add real coverage here. The OS axis below +# is the dimension that does: it exercises this workflow's own tooling (the .NET SDK, `dotnet format`, PowerShell +# `verify-package.ps1`) on both of this repository's supported development platforms. +# +# Supply-chain note: every action below is pinned to an immutable full commit SHA (not a mutable tag) with a +# trailing `# vX.Y.Z` comment recording the release that SHA corresponds to, matching dotnet-security.yml. +# +# Compatibility-matrix note: the dotnet-agent-framework-compat job below is a separate credential-free job (not +# an extra dotnet-quality matrix dimension) because it deliberately re-restores/builds/tests against a *different* +# Agent Framework version per matrix entry via an MSBuild property override, rather than exercising this +# workflow's own OS/tooling variance; see dotnet/scripts/verify-agent-framework-compatibility.ps1. +on: + pull_request: + push: + branches: + - main + - build/dotnet-packaging-release + workflow_dispatch: + +permissions: + contents: read + +jobs: + dotnet-quality: + name: dotnet-quality (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore + working-directory: dotnet + run: dotnet restore MongoDB.AgentFramework.slnx + + # Covers "formatting/analyzer validation" from docs/spec/quality-release.md's .NET quality gates. Analyzer + # diagnostics (including the PublicAPI analyzer's RS0016/RS0017 for undeclared public API surface changes) + # are separately promoted to build errors by each project's TreatWarningsAsErrors, so the build step below + # catches those; this step catches whitespace/style formatting drift specifically. + - name: Verify formatting (no changes) + working-directory: dotnet + run: dotnet format MongoDB.AgentFramework.slnx --verify-no-changes --verbosity diagnostic + + - name: Build all target frameworks (Release, warnings as errors) + working-directory: dotnet + run: dotnet build MongoDB.AgentFramework.slnx --configuration Release --no-restore + + # Every project in the solution sets TreatWarningsAsErrors, so a clean exit code here already proves zero + # warnings; Search-capable-deployment integration tests (Trait Category integration-*) skip cleanly (not + # fail) without MONGODB_URI/MONGODB_DATABASE, exactly as they do for a local contributor without credentials. + - name: Unit tests (Release, every credential-free project with unique TRX) + shell: pwsh + working-directory: dotnet + run: > + ./scripts/invoke-test-projects-with-trx.ps1 -Configuration Release -NoBuild + -ResultsDirectory "artifacts/quality-test-results/${{ runner.os }}" + + - name: Upload quality TRX evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dotnet-quality-trx-${{ runner.os }} + path: dotnet/artifacts/quality-test-results/${{ runner.os }}/*.trx + if-no-files-found: error + retention-days: 14 + + # Pack, package-content allowlist, reproducibility (double-pack comparison), and the clean isolated NuGet + # consumer smoke test (tests/PackageSmokeTest) all live in one script so local and CI runs exercise + # identically; see dotnet/scripts/verify-package.ps1 for the full step-by-step rationale. + - name: Pack and verify the package (allowlist, reproducibility, consumer smoke) + shell: pwsh + working-directory: dotnet + run: ./scripts/verify-package.ps1 -Configuration Release + + # `dotnet build` only ever accepts one project or solution argument (MSB1008: "Only one project can + # be specified" if given several csproj paths on one command line), so every sample must be built in its + # own invocation. This loop discovers every directory under samples/ instead of hardcoding a project list, + # so a future new/removed/renamed sample can never silently drift out of sync with this check. + - name: Build all sample projects + shell: pwsh + working-directory: dotnet + run: | + $ErrorActionPreference = 'Stop' + $samples = Get-ChildItem -Path samples -Directory | Select-Object -ExpandProperty Name + if ($samples.Count -eq 0) { throw 'No sample projects found under samples/.' } + foreach ($sample in $samples) { + Write-Output "== Building sample: $sample ==" + dotnet build "samples/$sample/$sample.csproj" --configuration Release --no-restore + if ($LASTEXITCODE -ne 0) { throw "Sample '$sample' failed to build (exit code $LASTEXITCODE)." } + } + Write-Output "All $($samples.Count) sample projects built successfully." + + - name: Upload packed artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nuget-packages-${{ matrix.os }} + path: | + dotnet/artifacts/packages/*.nupkg + dotnet/artifacts/packages/*.snupkg + if-no-files-found: error + retention-days: 14 + + resolve-agent-framework-versions: + name: Resolve minimum and newest supported stable Agent Framework versions + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.resolve.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: 10.0.x + - name: Resolve common listed stable matrix bounds within the declared supported range + id: resolve + shell: pwsh + working-directory: dotnet + run: ./scripts/resolve-agent-framework-versions.ps1 -Mode StablePair + - name: Upload machine-readable and Markdown resolution report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-framework-version-resolution + path: dotnet/artifacts/agent-framework-version-resolution/* + if-no-files-found: error + retention-days: 14 + + dotnet-agent-framework-compat: + name: dotnet-agent-framework-compat (${{ matrix.agent-framework-version }}) + runs-on: ubuntu-latest + needs: resolve-agent-framework-versions + # Compatibility-matrix gate for docs/spec/compatibility-migration.md: MongoDB.AgentFramework.csproj and + # MongoDB.AgentFramework.Tests.csproj both reference Microsoft.Agents.AI.Abstractions/Workflows through a + # single $(AgentFrameworkVersion) MSBuild property that defaults to the tracked [1.13.0,1.17.0) range. This + # job overrides that property to the dynamically resolved oldest and newest common listed stable versions that + # still satisfy the package's declared support range. This is bound verification, not a + # support-range widening, and uses + # `-p:AgentFrameworkVersion=` on restore/build/test -- it never edits a tracked .csproj, and + # the package's own default range is unaffected (see dotnet/scripts/verify-agent-framework-compatibility.ps1, + # which this job runs, for the full step-by-step rationale and local-repro instructions). Credential-free: + # safe to run on every pull request, including from forks. + strategy: + fail-fast: false + matrix: + agent-framework-version: ${{ fromJSON(needs.resolve-agent-framework-versions.outputs.matrix) }} + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Verify Agent Framework compatibility matrix bound + shell: pwsh + working-directory: dotnet + run: ./scripts/verify-agent-framework-compatibility.ps1 -Configuration Release -Versions "${{ matrix.agent-framework-version }}" + + - name: Upload TRX and compatibility reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-framework-compat-${{ matrix.agent-framework-version }} + path: dotnet/artifacts/agent-framework-compat-results/* + if-no-files-found: error + retention-days: 14 + + dotnet-manifest-readiness: + name: .NET manifest release readiness + if: >- + always() && + ((github.event_name == 'push' && + github.ref == 'refs/heads/build/dotnet-packaging-release') || + (github.event_name == 'pull_request' && + github.base_ref == 'build/dotnet-packaging-release')) + needs: [dotnet-quality, dotnet-agent-framework-compat] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Check out exact build-branch commit + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Require every readiness dependency to succeed + shell: pwsh + env: + DOTNET_QUALITY_RESULT: ${{ needs.dotnet-quality.result }} + COMPATIBILITY_RESULT: ${{ needs.dotnet-agent-framework-compat.result }} + run: > + ./dotnet/scripts/assert-required-job-results.ps1 + -DotNetQualityResult $env:DOTNET_QUALITY_RESULT + -CompatibilityResult $env:COMPATIBILITY_RESULT + + - name: Set up .NET SDKs + uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Validate manifest, package, and remote tag readiness + shell: pwsh + run: ./dotnet/scripts/verify-build-release-readiness.ps1 + + - name: Upload manifest readiness report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dotnet-build-release-readiness-${{ github.sha }} + path: dotnet/artifacts/build-release-readiness/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/dotnet-release-attestation.yml b/.github/workflows/dotnet-release-attestation.yml new file mode 100644 index 0000000..4fffc27 --- /dev/null +++ b/.github/workflows/dotnet-release-attestation.yml @@ -0,0 +1,537 @@ +name: .NET release provenance attestation + +# Requests a GitHub build-provenance attestation for a validated MongoDB.AgentFramework release artifact. This +# is the ONLY workflow in this repository holding `id-token`/`attestations`/`artifact-metadata` write +# permissions, and it is deliberately triggered EXCLUSIVELY by `workflow_run` -- never `push`, `workflow_dispatch`, +# `release`, or any other event a pusher/operator/tag-creator can pick the reacting ref for. +# +# Why: an earlier revision of this repository's attestation workflow held these elevated permissions in the SAME +# file as its `pull_request`/`push`/`workflow_dispatch`-triggered `sbom` job, and split eligibility validation +# into a separate in-file job that checked out and ran its scripts from the repository's real `main` branch +# (never `github.ref`) before the OIDC-permissioned job started. That fix was INSUFFICIENT: GitHub Actions +# resolves and runs an ENTIRE workflow file's job graph -- every job's own `permissions:`/`needs:`/`if:` +# definitions and step list, not merely the scripts a job happens to invoke -- using the exact file content +# present at whatever ref triggered that specific run. For `push` (including a tag push) and `workflow_dispatch`, +# that ref is the pushed tag's target commit or whatever branch/tag an operator selected at dispatch time -- +# content this repository does not control the review of before the run starts. A workflow file that both (a) +# was triggered by one of those events and (b) held elevated permissions on ANY job could therefore have that +# job's own permissions/needs/if wiring -- not just its called scripts -- silently rewritten by whoever controls +# the triggering ref, completely defeating an in-file "trusted validator job" split regardless of how carefully +# that job's own steps were written. +# +# `workflow_run` is the one trigger GitHub's own documentation guarantees always resolves and runs the reacting +# workflow's file exactly as it exists on the repository's DEFAULT branch, regardless of what ref/event triggered +# the upstream run it reacts to (GitHub Actions reference: the `workflow_run` event's table entry documents +# `GITHUB_SHA`/`GITHUB_REF` as "Last commit on default branch"/"Default branch", and explicitly notes "This event +# will only trigger a workflow run if the workflow file exists on the default branch" -- the same guarantee +# `schedule`/`issues`/other ref-agnostic events carry, and which `push`/`workflow_dispatch`/`release` do NOT: the +# `release` event's own table entry documents `GITHUB_SHA`/`GITHUB_REF` as "Last commit in the tagged +# release"/"Tag ref of release", with no default-branch note at all -- i.e. `release: types: [published]` would +# NOT have closed this gap, contrary to an earlier proposal, since it resolves the workflow file from the +# RELEASED TAG's own commit, structurally identical in trust to a tag `push`). GitHub's "Secure use reference" +# and the `workflow_run` event's own documentation describe this exact pattern -- an unprivileged workflow builds +# in an untrusted context, a separate `workflow_run`-triggered workflow then takes privileged action -- as the +# recommended way to grant elevated permissions without the fully-untrusted-ref exposure `pull_request_target` +# and, per the above, `push`/`workflow_dispatch`/`release` all share to varying degrees. +# +# This workflow reacts to `.github/workflows/dotnet-sbom-provenance.yml` ("`.NET package SBOM (credential-free +# verification)`") completing -- that upstream workflow itself holds NO elevated permissions on any job (see its +# own header comment) and is safe to run against untrusted fork pull_request code for exactly that reason, since +# a compromised/attacker-controlled copy of ITS job graph can, at most, cause an inert SBOM/package artifact +# upload or a spurious `workflow_run` event -- never anything privileged. This workflow never trusts anything +# that upstream run built or asserted: +# +# - `validate-attestation-eligibility` (`permissions: contents: read` only) checks out this repository's `main` +# (redundant with, but explicit and auditable alongside, the `workflow_run` trigger's own default-branch +# guarantee) and reconstructs a candidate full ref from the upstream run's bare, GitHub-verified +# `head_branch`/`event`/`head_sha` fields (`dotnet/scripts/verify-workflow-run-attestation-ref.ps1`, backed by +# `Resolve-WorkflowRunAttestationRef`/`Test-AttestationRefEligible` in +# `dotnet/scripts/ReleaseVersionTag.ps1`) -- independently confirming, via this trusted checkout's OWN git +# history, that a claimed tag genuinely exists and points at the exact claimed commit (GitHub's +# `workflow_run` payload cannot otherwise distinguish "a tag named X" from "a branch named X" by name alone). +# A second step proves the claimed commit is real, ancestry-verified history of `origin/main` +# (`git merge-base --is-ancestor`) -- catching a tag pushed against a commit that was never actually merged. +# Coordinator dispatch is bound to the real `dotnet-v*` tag and exact tag commit; ordinary manual dispatch +# remains restricted to `refs/heads/main`. The validator independently resolves any tag candidate against +# trusted git history and the rebuilt package version. +# - `provenance-attestation` (`needs: validate-attestation-eligibility`) checks out the validated, ancestry- +# proven commit SHA (never anything from the upstream/triggering ref of the SBOM workflow, and never +# `github.sha`/`github.ref` of THIS workflow's own `workflow_run` context, which always describes `main`'s +# current tip, not the artifact's commit) and REBUILDS the package fresh with `verify-package.ps1` from that +# checkout -- it never trusts or downloads the upstream `sbom` job's own uploaded artifact, so the attested +# bytes are provably produced by this trusted, always-default-branch-sourced workflow from ancestry-verified +# content, not by a job whose own workflow-file trust this design no longer relies on. It independently +# re-verifies the tag/package-version match (for a tag-push-derived ref) before ever attesting, then generates +# a CUSTOM SLSA v1.0 provenance predicate (dotnet/scripts/ReleaseProvenancePredicate.ps1) that explicitly +# binds `resolvedDependencies` to the validated commit SHA, and requests an attestation over it via +# `actions/attest`'s custom-predicate mode -- deliberately NOT the stock `actions/attest-build-provenance` +# auto-provenance mode, which would derive its predicate's build-source solely from this job's own ambient +# `GITHUB_SHA`/`GITHUB_REF` (documented by GitHub, for `workflow_run`, as "Last commit on default branch"/ +# "Default branch" -- i.e. this workflow's own trigger context, never the validated release commit actually +# checked out and rebuilt; checking out a different commit does not alter those ambient values). Using the +# stock mode here would silently attest misleading provenance. `runDetails.builder.id` inside the custom +# predicate still correctly and honestly identifies this workflow/ref as the actual builder, which it +# genuinely is. The `dotnet-release-attestation` GitHub Environment (an owner-configured protection gate) +# remains a genuine, additional, independent layer of defense-in-depth, not this design's primary or sole +# gate. +# +# NuGet package signing (Authenticode-style code signing over the .nupkg itself, distinct from the SLSA-style +# build attestation above) remains BLOCKED: it requires an owner-issued, organization-approved code-signing +# certificate and a decision on trusted-signing infrastructure, both of which depend on +# docs/decisions/0013-establish-project-and-publishing-governance.md, which is still `proposed` (unconfirmed) as +# of this workflow's authoring. Inventing a certificate/secret here would fabricate an unconfirmed publishing +# identity, which this repository's engineering workflow explicitly forbids. The signing step below is kept +# workflow-ready (correct command, correct inputs) but permanently disabled (`if: false`) with this blocker +# documented inline; see dotnet/README.md's Status section for the same blocker surfaced to package consumers. +# +# Supply-chain note: actions pinned exactly as in dotnet-security.yml/dotnet-quality.yml/dotnet-sbom-provenance.yml. +# `actions/attest` (not `actions/attest-build-provenance`, which this workflow never uses -- see above) is pinned +# to its own exact release commit SHA the same way. +on: + workflow_run: + workflows: [".NET package SBOM (credential-free verification)"] + types: [completed] + +permissions: + contents: read + +jobs: + validate-attestation-eligibility: + name: Validate attestation eligibility (trusted default-branch workflow; no OIDC permissions) + # Ordinary branch/PR SBOM runs intentionally produce credential-free evidence only. Release validation is + # entered solely for a release-tag push or the coordinator's explicit workflow_dispatch. + if: >- + github.event.workflow_run.conclusion == 'success' && + (github.event.workflow_run.event == 'workflow_dispatch' || + startsWith(github.event.workflow_run.head_branch, 'dotnet-v')) + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + validated-sha: ${{ steps.record-sha.outputs.sha }} + is-tag-push: ${{ steps.validate-ref.outputs.is-tag-push }} + tag-name: ${{ steps.validate-ref.outputs.tag-name }} + release-tag: ${{ steps.release-tag.outputs.tag }} + steps: + # Explicit and auditable alongside (never a substitute for) this workflow's own `workflow_run` trigger, + # which GitHub guarantees already resolves this entire workflow's job graph from the default branch + # regardless of what triggered the upstream `sbom` run. `fetch-depth: 0` (full history, not the default + # shallow clone) makes `origin/main` and every tag object available locally for the checks below. + - name: Checkout trusted main + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: main + fetch-depth: 0 + + # `github.event.workflow_run.event`/`.head_branch`/`.head_sha` are the UPSTREAM sbom run's own + # GitHub-verified event metadata (never executable code -- an event-type string, a bare ref short name, + # and a commit SHA), passed through step-level `env:` values and never interpolated into script source, for + # the same injection rationale documented throughout this repository's release scripts. This step + # reconstructs a candidate full ref from those bare fields and independently verifies -- using ONLY this + # trusted `main` checkout's real git history, never anything from the upstream run's own ref -- that a + # claimed tag genuinely exists and matches the claimed commit, then applies the same + # Test-AttestationRefEligible gate the previous (now-decommissioned) in-file validator used. This script is + # ALSO the sole, authoritative writer of this step's `is-tag-push`/`tag-name` outputs (see its own .OUTPUTS + # doc comment) -- the job's `outputs:` map below MUST read them from `steps.validate-ref.outputs.*`, never + # from a different step id (a prior revision mis-wired this to `record-sha`, a step that never sets either + # value, silently emitting an empty string and skipping the tag/version match check on every run; see + # verify-release-attestation-job-wiring.tests.ps1's regression proof). + - name: Validate upstream event/ref/commit is eligible + id: validate-ref + shell: pwsh + working-directory: dotnet + env: + UPSTREAM_EVENT_NAME: ${{ github.event.workflow_run.event }} + UPSTREAM_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + UPSTREAM_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + ./scripts/verify-workflow-run-attestation-ref.ps1 ` + -UpstreamEventName $env:UPSTREAM_EVENT_NAME ` + -UpstreamHeadBranch $env:UPSTREAM_HEAD_BRANCH ` + -UpstreamHeadSha $env:UPSTREAM_HEAD_SHA ` + -RepositoryRoot ${{ github.workspace }} + + # Ancestry/commit validation: proves the actual upstream commit is reachable from origin/main's history, + # not merely that its ref NAME matched the expected shape above -- catching e.g. a `dotnet-v*` tag pushed + # against a commit that was never actually merged into `main`. + - name: Validate commit is reachable from origin/main (ancestry check) + shell: bash + env: + ATTESTATION_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + if ! git merge-base --is-ancestor "$ATTESTATION_SHA" origin/main; then + echo "::error::Commit $ATTESTATION_SHA is not an ancestor of origin/main; refusing to attest provenance for a commit that never reached the protected main branch." + exit 1 + fi + echo "Commit $ATTESTATION_SHA is reachable from origin/main." + + - name: Resolve matching manifest release tag + id: release-tag + shell: pwsh + env: + ATTESTATION_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + $projectText = git show "$($env:ATTESTATION_SHA):dotnet/src/MongoDB.AgentFramework/MongoDB.AgentFramework.csproj" + if ($LASTEXITCODE -ne 0) { throw 'Unable to read the package manifest at the validated commit.' } + [xml]$project = $projectText + $tag = "dotnet-v$([string]$project.Project.PropertyGroup.Version)" + git show-ref --verify --quiet "refs/tags/$tag" + if ($LASTEXITCODE -eq 0) { + $tagCommit = (git rev-parse "$tag^{commit}").Trim() + if ($tagCommit -cne $env:ATTESTATION_SHA) { + throw "Manifest tag '$tag' exists but does not target validated commit $env:ATTESTATION_SHA." + } + "tag=$tag" | Add-Content $env:GITHUB_OUTPUT + } + else { + "tag=" | Add-Content $env:GITHUB_OUTPUT + } + + # Records the plain, already-GitHub-verified upstream commit SHA as this job's output, for + # `provenance-attestation` to check out and rebuild from below -- the ONLY commit that job is allowed to + # trust, never `github.sha`/`github.ref` of this workflow's own `workflow_run` context (which always + # describes `main`'s tip, not the artifact's commit). + - name: Record validated commit SHA + id: record-sha + shell: bash + env: + ATTESTATION_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + echo "sha=$ATTESTATION_SHA" >> "$GITHUB_OUTPUT" + + resolve-release-compatibility: + name: Resolve release Agent Framework compatibility versions + needs: validate-attestation-eligibility + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.resolve.outputs.matrix }} + steps: + - name: Checkout validated release commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.validate-attestation-eligibility.outputs.validated-sha }} + persist-credentials: false + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: 10.0.x + - name: Resolve latest and previous common listed stable versions + id: resolve + shell: pwsh + working-directory: dotnet + run: ./scripts/resolve-agent-framework-versions.ps1 -Mode StablePair + + release-compatibility: + name: Release compatibility (${{ matrix.version }}) + needs: [validate-attestation-eligibility, resolve-release-compatibility] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.resolve-release-compatibility.outputs.matrix) }} + permissions: + contents: read + steps: + - name: Checkout validated release commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.validate-attestation-eligibility.outputs.validated-sha }} + persist-credentials: false + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + - name: Verify exact release compatibility + shell: pwsh + working-directory: dotnet + run: ./scripts/verify-agent-framework-compatibility.ps1 -Configuration Release -Versions "${{ matrix.version }}" + - name: Upload release compatibility evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-compatibility-${{ matrix.version }} + path: dotnet/artifacts/agent-framework-compat-results/* + if-no-files-found: error + retention-days: 90 + + provenance-attestation: + name: Rebuild and attest build provenance (validated release ref only) + needs: [validate-attestation-eligibility, release-compatibility] + runs-on: ubuntu-latest + environment: dotnet-release-attestation + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + steps: + # Checks out the EXACT commit SHA validate-attestation-eligibility already validated -- never anything + # from the upstream sbom run's own ref, and never this workflow's own `github.sha`/`github.ref` (which + # describe `main`'s current tip under a `workflow_run` trigger, not the artifact's commit). This checked-out + # content is used purely as ordinary build/pack input for the steps below, never executed as THIS + # workflow's own job/script definitions -- those remain sourced from `main` throughout, per the + # `workflow_run` trigger's own guarantee. Because the ancestry check above already proved this exact SHA is + # reachable from `origin/main`, its content is, by definition, already-reviewed/merged history -- identical + # in trust to building any other ordinary, reviewed commit in this repository. + - name: Checkout the validated commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.validate-attestation-eligibility.outputs.validated-sha }} + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Run all credential-free release tests with unique TRX evidence + shell: pwsh + working-directory: dotnet + run: | + ./scripts/invoke-test-projects-with-trx.ps1 ` + -Configuration Release -ResultsDirectory artifacts/release-test-results + + # Rebuilds the package fresh from the validated checkout -- the full verify-package.ps1 verification + # (pack, exact-match allowlist, nuspec metadata, double-pack reproducibility, and the multi-TFM isolated + # consumer smoke test) always runs here, regardless of trigger depth, since this is the one build whose + # output actually gets attested. + - name: Pack and fully verify the package from the validated commit + shell: pwsh + working-directory: dotnet + run: ./scripts/verify-package.ps1 -Configuration Release + + - name: Generate release SPDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dotnet/artifacts/packages + format: spdx-json + output-file: dotnet/artifacts/packages/MongoDB.AgentFramework.sbom.spdx.json + upload-artifact: false + + - name: Generate release CycloneDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dotnet/artifacts/packages + format: cyclonedx-json + output-file: dotnet/artifacts/packages/MongoDB.AgentFramework.sbom.cyclonedx.json + upload-artifact: false + + - name: Write release validation report and checksums + shell: pwsh + working-directory: dotnet + env: + VALIDATED_SHA: ${{ needs.validate-attestation-eligibility.outputs.validated-sha }} + run: | + @" + # MongoDB.AgentFramework release validation + + - Validated commit: `$env:VALIDATED_SHA` + - Credential-free tests: passed; see per-project TRX files + - Dynamic latest/previous common stable compatibility: passed in required upstream jobs + - Package metadata/content/reproducibility/local-feed smoke: passed + "@ | Set-Content artifacts/packages/release-validation.md + Get-ChildItem artifacts/packages -File | ForEach-Object { + $hash = Get-FileHash $_.FullName -Algorithm SHA256 + "$($hash.Hash) $($_.Name)" + } | Set-Content artifacts/packages/checksums.sha256.txt + + # Independently re-verifies the tag/package-version match from THIS trusted rebuild's own artifact, never + # trusting whatever the upstream (differently-triggered, no-OIDC) sbom job asserted about its own build. + - name: Verify tag matches the freshly rebuilt package version (tag-push-derived ref only) + if: needs.validate-attestation-eligibility.outputs.is-tag-push == 'true' + shell: pwsh + working-directory: dotnet + env: + RELEASE_TAG: ${{ needs.validate-attestation-eligibility.outputs.tag-name }} + run: | + $nupkg = Get-ChildItem artifacts/packages -Filter *.nupkg | Select-Object -First 1 -ExpandProperty FullName + ./scripts/verify-release-tag.ps1 -NupkgPath $nupkg -RefName $env:RELEASE_TAG -EnforceMatch + + # SLSA-style CUSTOM build provenance predicate, explicitly binding the attestation's subject artifacts to + # the validated commit SHA above -- see dotnet/scripts/ReleaseProvenancePredicate.ps1's header comment for + # the full rationale. The stock `actions/attest-build-provenance` action's default auto-provenance mode is + # DELIBERATELY NEVER USED in this workflow: it would populate its predicate's build-source information + # solely from this job's own ambient GITHUB_SHA/GITHUB_REF, which GitHub's own `workflow_run` documentation + # defines as "Last commit on default branch"/"Default branch" -- i.e. THIS workflow's own trigger context + # (main's tip), never the validated release commit checked out and rebuilt above (checking out a different + # commit does not alter those ambient values or the OIDC claims derived from them). Using that stock mode + # here would silently attest misleading provenance -- a signed statement whose claimed source commit is + # unrelated to the artifact's real, validated origin. This step runs the custom-predicate generator ONLY + # after the validated checkout, rebuild, and tag/version re-verification above -- it can never be reached + # with unvalidated content, and `IS_TAG_PUSH`/`RELEASE_TAG_NAME` are read from the SAME trusted + # `validate-attestation-eligibility` job outputs the tag/version check above already used. + - name: Generate custom release-provenance predicate binding the validated commit + id: generate-predicate + shell: pwsh + working-directory: dotnet + env: + VALIDATED_SHA: ${{ needs.validate-attestation-eligibility.outputs.validated-sha }} + REPOSITORY_SLUG: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + IS_TAG_PUSH: ${{ needs.validate-attestation-eligibility.outputs.is-tag-push }} + RELEASE_TAG_NAME: ${{ needs.validate-attestation-eligibility.outputs.tag-name }} + run: | + ./scripts/write-release-provenance-predicate.ps1 ` + -ValidatedSha $env:VALIDATED_SHA ` + -RepositorySlug $env:REPOSITORY_SLUG ` + -RunId $env:RUN_ID ` + -RunAttempt $env:RUN_ATTEMPT ` + -IsTagPush $env:IS_TAG_PUSH ` + -TagName $env:RELEASE_TAG_NAME ` + -OutputPath artifacts/release-provenance-predicate.json + + # Attests the freshly rebuilt artifact using the CUSTOM predicate generated above (never the stock + # `actions/attest-build-provenance` auto-provenance mode -- see the step above's rationale). `actions/attest` + # is the generic, lower-level action `actions/attest-build-provenance` is itself now merely a thin wrapper + # over (per its own README); it exposes the documented `predicate-type`/`predicate-path` custom-attestation + # inputs `attest-build-provenance` does not. `runDetails.builder.id` inside the predicate content still + # correctly and honestly identifies this workflow/ref as the actual builder, which it genuinely is + # (workflow_run guarantees this file's own content is always sourced from main). + - name: Attest custom release provenance (binds validated commit, never the stock auto-provenance mode) + id: attest + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: | + dotnet/artifacts/packages/*.nupkg + dotnet/artifacts/packages/*.snupkg + predicate-type: https://slsa.dev/provenance/v1 + predicate-path: dotnet/artifacts/release-provenance-predicate.json + + - name: Collect attestation and reports + shell: pwsh + working-directory: dotnet + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + run: | + Copy-Item $env:ATTESTATION_BUNDLE artifacts/packages/MongoDB.AgentFramework.provenance.json + Copy-Item artifacts/release-provenance-predicate.json artifacts/packages/ + Copy-Item artifacts/release-test-results/*.trx artifacts/packages/ + if (Test-Path artifacts/packages/checksums.sha256.txt) { + Remove-Item artifacts/packages/checksums.sha256.txt + } + Get-ChildItem artifacts/packages -File | ForEach-Object { + $hash = Get-FileHash $_.FullName -Algorithm SHA256 + "$($hash.Hash) $($_.Name)" + } | Set-Content artifacts/packages/checksums.sha256.txt + + - name: Upload exact release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dotnet-release-bundle + path: dotnet/artifacts/packages/* + if-no-files-found: error + retention-days: 90 + + # BLOCKED (see file header): no owner-issued NuGet code-signing certificate or ADR 0013 acceptance exists + # yet. This step is intentionally never executed (`if: false`); it documents the exact command a future, + # owner-approved signing secret would need, without inventing that secret or claiming a signature exists. + - name: "Sign NuGet package (blocked: no signing certificate/ADR 0013 acceptance yet)" + if: false + working-directory: dotnet + run: > + dotnet nuget sign artifacts/packages/*.nupkg + --certificate-path "${{ secrets.NUGET_SIGNING_CERTIFICATE_PATH }}" + --certificate-password "${{ secrets.NUGET_SIGNING_CERTIFICATE_PASSWORD }}" + --timestamper http://timestamp.digicert.com + + publish-nuget-and-github-release: + name: Publish NuGet package and create GitHub Release + needs: [validate-attestation-eligibility, release-compatibility, provenance-attestation] + if: >- + needs.validate-attestation-eligibility.outputs.release-tag != '' && + vars.NUGET_ENVIRONMENT != '' && + vars.NUGET_PUBLISHING_APPROVED == 'true' + runs-on: ubuntu-latest + environment: ${{ vars.NUGET_ENVIRONMENT }} + permissions: + contents: write + env: + RELEASE_TAG: ${{ needs.validate-attestation-eligibility.outputs.release-tag }} + NUGET_SOURCE_URL: ${{ vars.NUGET_SOURCE_URL }} + steps: + - name: Download exact attested release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dotnet-release-bundle + path: release-bundle + - name: Download dynamic compatibility reports for the release commit + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-compatibility-* + path: compatibility-evidence + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: 10.0.x + - name: Validate canonical NuGet source before exposing publishing secret + shell: pwsh + run: | + if ($env:NUGET_SOURCE_URL -cne 'https://api.nuget.org/v3/index.json') { + throw 'NUGET_SOURCE_URL must be exactly https://api.nuget.org/v3/index.json; refusing to send the API key elsewhere.' + } + - name: Publish exact nupkg and snupkg + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) { + throw 'NUGET_API_KEY is missing from the protected NuGet environment.' + } + $packages = @(Get-ChildItem release-bundle -Filter '*.nupkg' | + Where-Object { $_.Name -notlike '*.snupkg' }) + $symbols = @(Get-ChildItem release-bundle -Filter '*.snupkg') + if ($packages.Count -ne 1 -or $symbols.Count -ne 1) { + throw "Expected exactly one nupkg and one snupkg; found $($packages.Count) and $($symbols.Count)." + } + dotnet nuget push $packages[0].FullName ` + --api-key $env:NUGET_API_KEY --source $env:NUGET_SOURCE_URL ` + --symbol-api-key $env:NUGET_API_KEY --symbol-source $env:NUGET_SOURCE_URL + if ($LASTEXITCODE -ne 0) { throw 'NuGet publication failed.' } + - name: Verify published package is restorable from configured feed + shell: pwsh + run: | + $version = $env:RELEASE_TAG.Substring('dotnet-v'.Length) + New-Item -ItemType Directory published-consumer | Out-Null + dotnet new console --output published-consumer --framework net10.0 --no-restore + dotnet add published-consumer package MongoDB.AgentFramework ` + --version $version --source $env:NUGET_SOURCE_URL --no-restore + if ($LASTEXITCODE -ne 0) { throw 'Unable to configure the published-package consumer.' } + $restored = $false + for ($attempt = 1; $attempt -le 12; $attempt++) { + dotnet restore published-consumer --force --no-cache --source $env:NUGET_SOURCE_URL + if ($LASTEXITCODE -eq 0) { $restored = $true; break } + Start-Sleep -Seconds 10 + } + if (-not $restored) { throw 'Published package was not restorable after bounded feed propagation retries.' } + - name: Create immutable GitHub Release with exact bundle + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + foreach ($directory in Get-ChildItem compatibility-evidence -Directory) { + $version = $directory.Name.Substring('release-compatibility-'.Length) + Copy-Item (Join-Path $directory.FullName 'compatibility-report.json') ` + "release-bundle/compatibility-$version.json" + Copy-Item (Join-Path $directory.FullName 'compatibility-report.md') ` + "release-bundle/compatibility-$version.md" + Get-ChildItem $directory.FullName -Filter '*.trx' -Recurse | ForEach-Object { + Copy-Item $_.FullName "release-bundle/$version-$($_.Name)" + } + } + Remove-Item release-bundle/checksums.sha256.txt + Get-ChildItem release-bundle -File | ForEach-Object { + $hash = Get-FileHash $_.FullName -Algorithm SHA256 + "$($hash.Hash) $($_.Name)" + } | Set-Content release-bundle/checksums.sha256.txt + $assets = @(Get-ChildItem release-bundle -File | ForEach-Object FullName) + gh release create $env:RELEASE_TAG @assets --verify-tag ` + --title "MongoDB.AgentFramework $($env:RELEASE_TAG.Substring('dotnet-v'.Length))" --generate-notes diff --git a/.github/workflows/dotnet-release.yml b/.github/workflows/dotnet-release.yml new file mode 100644 index 0000000..f585664 --- /dev/null +++ b/.github/workflows/dotnet-release.yml @@ -0,0 +1,95 @@ +name: .NET release coordinator + +on: + push: + branches: + - main + paths: + - dotnet/src/MongoDB.AgentFramework/MongoDB.AgentFramework.csproj + workflow_dispatch: + inputs: + confirm_release: + description: Type RELEASE to create the manifest tag and start the protected release chain + required: true + type: string + +permissions: + contents: read + +concurrency: + group: dotnet-release-${{ github.sha }} + cancel-in-progress: false + +jobs: + create-tag-and-dispatch: + if: >- + github.ref == 'refs/heads/main' && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.confirm_release == 'RELEASE')) + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + steps: + - name: Checkout main with tags + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Setup .NET for canonical NuGet version parsing + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: 10.0.x + - name: Validate main reachability and derive manifest tag + id: release + shell: pwsh + run: | + git fetch origin main --tags + if ($LASTEXITCODE -ne 0) { throw 'Unable to fetch origin/main and tags.' } + $sha = '${{ github.sha }}' + $headSha = (git rev-parse HEAD).Trim() + if ($headSha -cne $sha) { throw "Checkout resolved '$headSha', expected workflow SHA '$sha'." } + git merge-base --is-ancestor $sha origin/main + if ($LASTEXITCODE -ne 0) { throw "Release commit $sha is not reachable from origin/main." } + . ./dotnet/scripts/ReleaseReadiness.ps1 + [xml]$project = Get-Content dotnet/src/MongoDB.AgentFramework/MongoDB.AgentFramework.csproj -Raw + $version = [string]$project.SelectSingleNode('/Project/PropertyGroup/Version').InnerText + $canonical = Get-CanonicalNuGetVersion $version + $tag = Get-CanonicalDotNetReleaseTag $canonical + $existingTagSha = '' + git show-ref --verify --quiet "refs/tags/$tag" + if ($LASTEXITCODE -eq 0) { + $existingTagSha = (git rev-list -n 1 "refs/tags/$tag").Trim() + } + $disposition = Get-ReleaseTagDisposition -ExpectedSha $sha -ExistingTagSha $existingTagSha + if ($disposition -ceq 'conflict') { + throw "Immutable tag '$tag' targets '$existingTagSha', not exact release SHA '$sha'." + } + "tag=$tag" | Add-Content $env:GITHUB_OUTPUT + "sha=$sha" | Add-Content $env:GITHUB_OUTPUT + "disposition=$disposition" | Add-Content $env:GITHUB_OUTPUT + - name: Create and push annotated release tag + if: steps.release.outputs.disposition == 'create' + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$RELEASE_TAG" "${{ steps.release.outputs.sha }}" -m "Release MongoDB.AgentFramework ${RELEASE_TAG#dotnet-v}" + git push origin "refs/tags/$RELEASE_TAG" + - name: Accept existing immutable tag at exact release SHA + if: steps.release.outputs.disposition == 'already-exact' + run: echo "Tag ${{ steps.release.outputs.tag }} already targets exact SHA ${{ steps.release.outputs.sha }}; continuing idempotent dispatch." + - name: Dispatch credential-free release build for the immutable release SHA + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: > + gh workflow run dotnet-sbom-provenance.yml --ref "$RELEASE_TAG" + -f release_sha="$RELEASE_SHA" -f release_tag="$RELEASE_TAG" + - name: Explain non-recursive chain + run: > + echo "Created ${{ steps.release.outputs.tag }} and explicitly dispatched the SBOM workflow. + Tags created with GITHUB_TOKEN do not recursively trigger push workflows; workflow_dispatch is the + documented GITHUB_TOKEN exception and its completion starts the trusted workflow_run attestation chain." diff --git a/.github/workflows/dotnet-sbom-provenance.yml b/.github/workflows/dotnet-sbom-provenance.yml new file mode 100644 index 0000000..4e6e131 --- /dev/null +++ b/.github/workflows/dotnet-sbom-provenance.yml @@ -0,0 +1,217 @@ +name: .NET package SBOM (credential-free verification) + +# Implements the SBOM portion of docs/spec/quality-release.md's "Package build and provenance" release- +# engineering requirements for the .NET package: +# +# - "Generate an SBOM for each release or repository release bundle." +# +# (Build-provenance ATTESTATION and NuGet signing live entirely in the SEPARATE, privileged +# `.github/workflows/dotnet-release-attestation.yml` workflow -- see that file's header comment for the full +# rationale. This file holds NO `id-token`/`attestations`/`artifact-metadata` permissions anywhere, on any job, +# because GitHub Actions resolves and runs an ENTIRE workflow file's job graph -- every job's own +# `permissions:`/`needs:`/`if:` definitions, not merely the scripts a job happens to invoke -- using the exact +# file content present at whatever ref triggered that specific run. For `pull_request`/`push`/`workflow_dispatch` +# [this file's only triggers], that ref is always either an operator-selected branch/tag or a pushed ref/commit +# -- content this repository does not unilaterally control the review of before the run starts. A job in THIS +# file that held elevated OIDC/attestation permissions could therefore have its own job definition -- not just +# the scripts it calls -- silently rewritten by whoever controls that ref, regardless of how carefully any +# individual step were written; a prior revision of this workflow attempted to close that gap with an in-file +# "trusted main checkout" validator job, which is INSUFFICIENT, because the validator job's own `permissions:`/ +# `needs:`/`if:` wiring is itself sourced from the same untrusted ref. Splitting the elevated job into a wholly +# separate file triggered only by `workflow_run` -- the one event GitHub's own documentation guarantees always +# resolves and runs the reacting workflow's file exactly as it exists on the repository's default branch, +# regardless of what ref/event triggered the upstream run it reacts to -- is the only trigger-level guarantee +# that actually closes this class of bug; see dotnet-release-attestation.yml's header comment for the full +# citation and design.) +# +# `sbom` runs dotnet/scripts/verify-package.ps1 (the same script dotnet-quality.yml runs) to pack and verify the +# library, then generates both an SPDX and a CycloneDX SBOM with anchore/sbom-action (a widely used, actively +# maintained tool that wraps Syft; SHA-pinned below like every other action in this repository) over that exact, +# already-verified primary .nupkg/.snupkg -- never an independent, unverified `dotnet pack`. On a `dotnet-v*` tag +# push (this workflow's `on:` is the only one of this repository's .NET workflows that triggers on a tag push at +# all) or a manual workflow_dispatch, it runs verify-package.ps1's FULL verification (allowlist, nuspec metadata, +# double-pack reproducibility, and the multi-TFM isolated consumer smoke test); for pull_request/push-to-main it +# runs the faster pack+allowlist+metadata-only path, since dotnet-quality.yml already runs the full script for +# those triggers. It also asserts, for a tag push only, that the packed .nuspec's exactly matches +# `dotnet-v` for the triggering tag (dotnet/scripts/verify-release-tag.ps1) before anything is uploaded +# -- a main-branch manual workflow_dispatch only records the version, while a coordinator dispatch against an +# immutable release tag enforces the same exact tag/package match as a tag push. This job requires no secrets and no elevated permissions beyond `contents: read`, so it +# is safe to run on every pull request, including from forks. Note that a SUCCESSFUL run of this job here -- +# regardless of which ref/event triggered it, including an untrusted fork's pull_request -- fires a +# `workflow_run` event; `dotnet-release-attestation.yml` independently re-validates (using its own, +# always-default-branch-sourced scripts, never anything from this file or the ref that triggered THIS run) +# whether that upstream event/ref/commit is genuinely eligible before ever attesting anything, and rebuilds the +# package fresh from its own validated, ancestry-verified checkout rather than trusting any artifact this job +# uploads -- this job's uploaded `.nupkg`/SBOM/checksums remain a purely informational, early feedback signal for +# maintainers, never the thing that gets attested. +on: + pull_request: + push: + branches: + - main + - build/dotnet-packaging-release + tags: + - "dotnet-v*" + workflow_dispatch: + inputs: + release_sha: + description: Immutable main-reachable commit to build; supplied only by the release coordinator + required: false + type: string + release_tag: + description: Matching immutable dotnet-v tag; supplied only by the release coordinator + required: false + type: string + +permissions: + contents: read + +jobs: + sbom: + name: SBOM (credential-free) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ inputs.release_sha || github.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + # A `dotnet-v*` tag push and an operator-initiated workflow_dispatch both run the FULL verify-package.ps1 + # (pack, exact-match allowlist, nuspec metadata, double-pack reproducibility, and the multi-TFM isolated + # consumer smoke test) -- this workflow is the only one that ever runs on a tag push (dotnet-quality.yml's + # `on:` only covers pull_request/push-to-main/workflow_dispatch), so it cannot rely on that other workflow + # having already verified a tag-pushed artifact. A plain pull_request/push-to-main run here uses the faster + # pack+allowlist+metadata-only path (still never a naked/unverified `dotnet pack`) since dotnet-quality.yml + # already runs the full script for those triggers on both OS matrix legs. Regardless of depth, this job's + # output is never itself the artifact `dotnet-release-attestation.yml` attests -- that privileged workflow + # always rebuilds fresh from its own independently validated, ancestry-verified checkout. + # + # `github.event_name`/`github.ref` are passed through step-level `env:` values, never interpolated directly + # into this script's source text: GitHub Actions substitutes `${{ }}` expressions into the YAML string + # BEFORE the shell ever parses it, so a ref containing a quote, a `$()` subexpression, or a semicolon could + # otherwise break out of the intended string literal and execute arbitrary code on the runner. A tag name + # is not restricted to "safe" characters by git itself (only a handful of characters are forbidden, and + # `$`, `(`, `)`, backticks, and quotes are all still legal in a git ref), so this is a real injection + # surface, not a theoretical one -- see dotnet/scripts/ReleaseVersionTag.ps1's Test-ValidReleaseTagGrammar + # doc comment for the identical rationale already applied to the tag/version-match step further below. + - name: Determine verification depth + id: verify-depth + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" ]] || \ + { [[ "$EVENT_NAME" == "push" ]] && [[ "$REF" == refs/tags/dotnet-v* ]]; }; then + echo "full=true" >> "$GITHUB_OUTPUT" + else + echo "full=false" >> "$GITHUB_OUTPUT" + fi + + # Pack, package-content allowlist, nuspec metadata, and (on a trusted tag push or manual dispatch) + # reproducibility + consumer smoke all live in the one script dotnet-quality.yml also runs, so this job's + # SBOM/checksum/upload/attestation subject is always the exact same *verified* primary .nupkg/.snupkg this + # script already checked -- never an independent, unverified `dotnet pack` invocation. verify-package.ps1 + # never cleans dotnet/artifacts/packages itself (only its own scratch reproducibility-comparison directory), + # so the primary artifacts it just packed remain on disk for every step below. + - name: Pack and verify the package (allowlist, metadata; full verification on a trusted tag push/dispatch) + shell: pwsh + working-directory: dotnet + env: + # Passed through env, not interpolated into this script's source text, for the same reason as every + # other `${{ github.* }}`/`${{ steps.*.outputs.* }}` value in this workflow -- see this job's first + # step's comment. This particular value only ever contains the literal string "true" or "false" (this + # workflow's own prior step produced it), so it carries no realistic injection payload today, but is + # still passed this way so the pattern remains uniform and does not silently regress if that prior + # step's logic ever changes to derive this value from less-trusted input. + VERIFY_DEPTH_FULL: ${{ steps.verify-depth.outputs.full }} + run: | + # NOTE: switches must be splatted from a hashtable (@{ Name = $true }), never a string array of + # '-Name' tokens -- array splatting only maps to *positional* parameters and would otherwise pass + # these dashed strings straight through as positional arguments, which verify-package.ps1 has none of. + $extraArgs = @{} + if ($env:VERIFY_DEPTH_FULL -ne 'true') { + $extraArgs['SkipReproducibility'] = $true + $extraArgs['SkipConsumerSmoke'] = $true + } + ./scripts/verify-package.ps1 -Configuration Release @extraArgs + + # Best-effort, informational: refuses to let the tag name and this JOB's own record diverge for a tag + # push, and prints the version otherwise. `dotnet-release-attestation.yml` independently re-derives and + # re-verifies this exact match from its own trusted, rebuilt artifact before ever attesting anything -- + # this step's PASS/FAIL only affects this job's own SBOM/checksum artifacts, never attestation eligibility. + # See dotnet/scripts/verify-release-tag.ps1/ReleaseVersionTag.ps1 for the pure comparison logic and + # dotnet/scripts/verify-release-tag.tests.ps1 for its match/mismatch/pre-release self-test. + - name: Verify tag matches package version (dotnet-v* tag push) + if: >- + startsWith(github.ref, 'refs/tags/dotnet-v') && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + shell: pwsh + working-directory: dotnet + env: + # Passed as an environment variable, never interpolated into the script's source text below: a ref + # name containing a quote, `$()`, or a semicolon must be treated purely as inert data, not re-parsed + # as PowerShell syntax. See verify-release-tag.ps1's header comment for the full rationale. + RELEASE_TAG: ${{ github.ref_name }} + run: | + $nupkg = Get-ChildItem artifacts/packages -Filter *.nupkg | Select-Object -First 1 -ExpandProperty FullName + ./scripts/verify-release-tag.ps1 -NupkgPath $nupkg -RefName $env:RELEASE_TAG -EnforceMatch + + # A main-branch manual workflow_dispatch has no tag; coordinator tag dispatches use the enforced step above. + - name: Record package version for this run (main workflow_dispatch, no tag to match) + if: github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/') + shell: pwsh + working-directory: dotnet + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + $nupkg = Get-ChildItem artifacts/packages -Filter *.nupkg | Select-Object -First 1 -ExpandProperty FullName + ./scripts/verify-release-tag.ps1 -NupkgPath $nupkg -RefName $env:RELEASE_TAG + + - name: Generate SPDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dotnet/artifacts/packages + format: spdx-json + output-file: dotnet/artifacts/packages/MongoDB.AgentFramework.sbom.spdx.json + upload-artifact: false + + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: dotnet/artifacts/packages + format: cyclonedx-json + output-file: dotnet/artifacts/packages/MongoDB.AgentFramework.sbom.cyclonedx.json + upload-artifact: false + + - name: Checksum manifest + working-directory: dotnet/artifacts/packages + shell: pwsh + run: | + Get-ChildItem -File | ForEach-Object { + $hash = Get-FileHash -Path $_.FullName -Algorithm SHA256 + "$($hash.Hash) $($_.Name)" + } | Set-Content -Path checksums.sha256.txt + Get-Content checksums.sha256.txt + + - name: Upload packages, SBOMs, and checksums (informational -- never the artifact that gets attested) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nuget-sbom-and-package + path: | + dotnet/artifacts/packages/*.nupkg + dotnet/artifacts/packages/*.snupkg + dotnet/artifacts/packages/*.sbom.*.json + dotnet/artifacts/packages/checksums.sha256.txt + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/dotnet-security.yml b/.github/workflows/dotnet-security.yml new file mode 100644 index 0000000..6b07a8e --- /dev/null +++ b/.github/workflows/dotnet-security.yml @@ -0,0 +1,122 @@ +name: .NET dependency, secret, and code scanning + +# Implements the "security" job from docs/spec/quality-release.md's CI workflow topology, scoped to this change: +# a .NET NuGet (including transitive) dependency vulnerability audit, a repository secret scan, and CodeQL code +# scanning for the .NET provider. +# +# Supply-chain note: every action below is pinned to an immutable full commit SHA (not a mutable tag) with a +# trailing `# vX.Y.Z` comment recording the release that SHA corresponds to, so a compromised or republished tag +# cannot silently change what this workflow executes. The secret scan runs a repository-local, dependency-free +# `git grep` script (.github/scripts/secret-scan.sh) instead of downloading and executing any third-party +# scanner binary, avoiding both the supply-chain risk of an unverified download and the licensing questions +# around gitleaks' GitHub Action wrapper for private-organization use. CodeQL is GitHub's first-party action. +# Every job requests only the workflow permissions it actually needs. +on: + pull_request: + push: + branches: + - main + - build/dotnet-packaging-release + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + dotnet-vulnerability-audit: + name: .NET dependency vulnerability audit + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore + working-directory: dotnet + run: dotnet restore MongoDB.AgentFramework.slnx + + # `dotnet list package --vulnerable` always exits 0, even when it finds vulnerable packages, so the audit + # step must inspect its own output text and fail the job explicitly. `--include-transitive` is required so + # a vulnerability introduced only by a dependency-of-a-dependency is still caught, matching + # docs/spec/observability-security.md's "Run dependency and secret scanning in CI" requirement. + - name: Audit direct and transitive NuGet dependencies for known vulnerabilities + working-directory: dotnet + shell: pwsh + run: | + $output = dotnet list MongoDB.AgentFramework.slnx package --vulnerable --include-transitive 2>&1 | Tee-Object -Variable capturedOutput + $capturedOutput | Out-String -Stream | Write-Output + if ($capturedOutput -match 'has the following vulnerable packages') { + Write-Error "One or more NuGet packages (direct or transitive) have known vulnerabilities. See the audit output above." + exit 1 + } + + secret-scan: + name: Repository secret scan + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + # Proves the scanner itself still detects a real secret shape and still respects its documented + # SENTINEL-SECRET- test-fixture exclusion, using disposable scratch git repos so no secret-shaped + # content is ever committed to this repository. See .github/scripts/secret-scan.test.sh. + - name: Self-test the secret-scan script + shell: bash + run: bash .github/scripts/secret-scan.test.sh + + # No third-party binary is downloaded or executed here; see .github/scripts/secret-scan.sh for the + # pattern set and its documented limitations (working-tree-only, pattern-based, no entropy analysis). + # Invoked as `bash