diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..577e605 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## Summary + + + +## SemVer declaration + +- [ ] Exactly one PR label is set: `semver:patch`, `semver:minor`, or `semver:major`. +- [ ] The selected impact follows `docs/SECURITY-AND-RELEASE-GOVERNANCE.md`. + +## Security and contract checklist + +- [ ] Existing versioned prompt, skill, Markdown part, and output-schema files were not edited. +- [ ] New or changed sensitive variables are marked `redacted_in_logs: true`. +- [ ] No raw arguments, rendered messages, documents, responses, or secrets are logged. +- [ ] Schema, manifest, aliases, pins, changelog, and task index are updated when applicable. +- [ ] Security-sensitive changes have a security-reviewer assigned. +- [ ] Local build, tests, CLI validation, and relevant sample succeeded. + +## Validation evidence + + + diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 36233b9..2233dc4 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -2,16 +2,17 @@ ## Prompt contract validation -`validate-prompts.yml` runs for every pull request, on manual dispatch, and as a -reusable workflow. Its `prompt-contract` job performs the same deterministic -sequence on every run: +`validate-prompts.yml` runs for every pull request, for every push to `main`, on +manual dispatch, and as a reusable workflow. Its `prompt-contract` job performs +the same deterministic sequence on every run: 1. restore and build the solution in Release mode; 2. run the CLI `validate` command; -3. test manifest and embedded-resource consistency; -4. test schemas, published-artifact immutability, and render fixtures; -5. test CLI exit codes and diagnostics; -6. run the end-to-end consumer sample in Production with directory override +3. test PromptKit runtime and redaction security contracts; +4. test manifest and embedded-resource consistency; +5. test schemas, published-artifact immutability, and render fixtures; +6. test CLI exit codes and diagnostics; +7. run the end-to-end consumer sample in Production with directory override suppression enabled. Any failed command stops the job and leaves the check unsuccessful. Configure @@ -24,19 +25,35 @@ The equivalent local commands are: dotnet restore DevComputaria.Prompting.sln dotnet build DevComputaria.Prompting.sln --configuration Release --no-restore dotnet run --project tools/DevComputaria.Prompts.Cli --configuration Release --no-build -- validate +dotnet test tests/DevComputaria.PromptKit.Tests/DevComputaria.PromptKit.Tests.csproj --configuration Release --no-build --no-restore dotnet test tests/DevComputaria.Prompts.Tests/DevComputaria.Prompts.Tests.csproj --configuration Release --no-build --no-restore dotnet test tests/DevComputaria.Prompts.Contract.Tests/DevComputaria.Prompts.Contract.Tests.csproj --configuration Release --no-build --no-restore dotnet test tests/DevComputaria.Prompts.Cli.Tests/DevComputaria.Prompts.Cli.Tests.csproj --configuration Release --no-build --no-restore DOTNET_ENVIRONMENT=Production PROMPTS_DIRECTORY_OVERRIDE=./prompts dotnet run --project samples/Consumer.ImageAnalysis --configuration Release --no-build ``` -## Prompts package +## Release governance -`pack-prompts.yml` can only execute its `pack prompts` job after the reusable -prompt contract gate succeeds. A successful run uploads the generated `.nupkg` -as a workflow artifact tied to the commit SHA. A failed gate therefore produces -no releasable package artifact. +`release-governance.yml` requires exactly one `semver:patch`, `semver:minor`, or +`semver:major` label on every pull request. It runs the CLI `release-check` +against the base branch to calculate the minimum impact and reject in-place +changes to published artifacts. Configure branch protection to require +`release-governance / semver-and-immutability`. -The workflow intentionally does not push directly to NuGet. Publishing that -artifact to a package registry requires the release credentials and governance -defined by GH-015. +Manual release runs provide a previous Git ref, previous package version, next +package version, and declared impact. The pack workflows reject a version +transition that does not exactly match that declaration. + +## Package artifacts + +`pack-promptkit.yml` and `pack-prompts.yml` can only execute their packaging jobs +after the reusable prompt contract gate succeeds, including all four test +projects. A successful run uploads the generated `.nupkg` as a workflow artifact +tied to the commit SHA and publishes that same file to the repository owner's +GitHub Packages NuGet registry. A failed test or contract check therefore creates +neither a releasable artifact nor a registry version. + +Registry publication runs through the `production` environment with the +workflow-scoped `GITHUB_TOKEN` and the least-privilege `packages: write` +permission. Configure required reviewers on that environment as defined in +`docs/SECURITY-AND-RELEASE-GOVERNANCE.md`. diff --git a/.github/workflows/pack-promptkit.yml b/.github/workflows/pack-promptkit.yml index 4415d88..aac2c70 100644 --- a/.github/workflows/pack-promptkit.yml +++ b/.github/workflows/pack-promptkit.yml @@ -2,13 +2,91 @@ name: pack-promptkit on: workflow_dispatch: + inputs: + base-ref: + description: Previous release tag or Git ref + required: true + previous-version: + description: Previously published package version (X.Y.Z) + required: true + package-version: + description: Package version to create (X.Y.Z) + required: true + semver-impact: + description: Declared release impact + required: true + type: choice + options: [patch, minor, major] + +permissions: + contents: read + packages: write jobs: + contract-gate: + name: validate prompt contract + uses: ./.github/workflows/validate-prompts.yml + pack: + name: pack PromptKit + needs: contract-gate runs-on: ubuntu-latest + environment: production + timeout-minutes: 15 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: true steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + cache: true + cache-dependency-path: | + Directory.Packages.props + src/DevComputaria.PromptKit/DevComputaria.PromptKit.csproj + tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj + + - name: Restore package dependencies + run: dotnet restore tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj + + - name: Validate release version and impact + run: >- + dotnet run --project tools/DevComputaria.Prompts.Cli + --configuration Release --no-restore -- + release-check --base "${{ inputs['base-ref'] }}" + --impact "${{ inputs['semver-impact'] }}" + --previous-version "${{ inputs['previous-version'] }}" + --next-version "${{ inputs['package-version'] }}" + + - name: Pack validated runtime + run: >- + dotnet pack src/DevComputaria.PromptKit/DevComputaria.PromptKit.csproj + --configuration Release --no-restore --output artifacts + -p:PackageVersion="${{ inputs['package-version'] }}" + -p:RepositoryUrl="${{ github.server_url }}/${{ github.repository }}" + -p:RepositoryType=git + -p:PublishRepositoryUrl=true + -p:ContinuousIntegrationBuild=true + + - name: Publish package artifact + uses: actions/upload-artifact@v4 with: - dotnet-version: 8.0.x - - run: dotnet pack src/DevComputaria.PromptKit/DevComputaria.PromptKit.csproj -c Release + name: DevComputaria.PromptKit-${{ inputs['package-version'] }}-${{ github.sha }} + path: artifacts/*.nupkg + if-no-files-found: error + retention-days: 14 + + - name: Publish package to GitHub Packages + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: >- + dotnet nuget push "artifacts/*.nupkg" + --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" + --api-key "$NUGET_AUTH_TOKEN" + --skip-duplicate diff --git a/.github/workflows/pack-prompts.yml b/.github/workflows/pack-prompts.yml index a67ea82..e37d219 100644 --- a/.github/workflows/pack-prompts.yml +++ b/.github/workflows/pack-prompts.yml @@ -2,9 +2,25 @@ name: pack-prompts on: workflow_dispatch: + inputs: + base-ref: + description: Previous release tag or Git ref + required: true + previous-version: + description: Previously published package version (X.Y.Z) + required: true + package-version: + description: Package version to create (X.Y.Z) + required: true + semver-impact: + description: Declared release impact + required: true + type: choice + options: [patch, minor, major] permissions: contents: read + packages: write jobs: contract-gate: @@ -15,6 +31,7 @@ jobs: name: pack prompts needs: contract-gate runs-on: ubuntu-latest + environment: production timeout-minutes: 15 env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 @@ -22,6 +39,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up .NET SDK uses: actions/setup-dotnet@v4 @@ -34,17 +53,40 @@ jobs: src/DevComputaria.Prompts/DevComputaria.Prompts.csproj - name: Restore package dependencies - run: dotnet restore src/DevComputaria.Prompts/DevComputaria.Prompts.csproj + run: dotnet restore tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj + + - name: Validate release version and impact + run: >- + dotnet run --project tools/DevComputaria.Prompts.Cli + --configuration Release --no-restore -- + release-check --base "${{ inputs['base-ref'] }}" + --impact "${{ inputs['semver-impact'] }}" + --previous-version "${{ inputs['previous-version'] }}" + --next-version "${{ inputs['package-version'] }}" - name: Pack validated catalog run: >- dotnet pack src/DevComputaria.Prompts/DevComputaria.Prompts.csproj --configuration Release --no-restore --output artifacts + -p:PackageVersion="${{ inputs['package-version'] }}" + -p:RepositoryUrl="${{ github.server_url }}/${{ github.repository }}" + -p:RepositoryType=git + -p:PublishRepositoryUrl=true + -p:ContinuousIntegrationBuild=true - name: Publish package artifact uses: actions/upload-artifact@v4 with: - name: DevComputaria.Prompts-${{ github.sha }} + name: DevComputaria.Prompts-${{ inputs['package-version'] }}-${{ github.sha }} path: artifacts/*.nupkg if-no-files-found: error retention-days: 14 + + - name: Publish package to GitHub Packages + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: >- + dotnet nuget push "artifacts/*.nupkg" + --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" + --api-key "$NUGET_AUTH_TOKEN" + --skip-duplicate diff --git a/.github/workflows/release-governance.yml b/.github/workflows/release-governance.yml new file mode 100644 index 0000000..94f1ed9 --- /dev/null +++ b/.github/workflows/release-governance.yml @@ -0,0 +1,79 @@ +name: release-governance + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + workflow_dispatch: + inputs: + base-ref: + description: Git ref used as the governance comparison baseline + required: true + semver-impact: + description: Declared release impact + required: true + type: choice + options: [patch, minor, major] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + governance: + name: semver-and-immutability + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: true + steps: + - name: Checkout full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve declared impact and base ref + id: release + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_BASE_REF: ${{ inputs['base-ref'] }} + INPUT_IMPACT: ${{ inputs['semver-impact'] }} + LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} + PR_BASE_REF: ${{ github.base_ref }} + run: | + if [[ "$EVENT_NAME" == "pull_request" ]]; then + mapfile -t semver_labels < <(jq -r '.[] | select(test("^semver:(patch|minor|major)$"))' <<< "$LABELS_JSON") + if [[ "${#semver_labels[@]}" -ne 1 ]]; then + echo "Exactly one semver:patch, semver:minor, or semver:major label is required." >&2 + exit 1 + fi + echo "impact=${semver_labels[0]#semver:}" >> "$GITHUB_OUTPUT" + echo "base=origin/$PR_BASE_REF" >> "$GITHUB_OUTPUT" + else + echo "impact=$INPUT_IMPACT" >> "$GITHUB_OUTPUT" + echo "base=$INPUT_BASE_REF" >> "$GITHUB_OUTPUT" + fi + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + cache: true + cache-dependency-path: | + Directory.Packages.props + tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj + + - name: Restore release tooling + run: dotnet restore tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj + + - name: Validate release impact + run: >- + dotnet run --project tools/DevComputaria.Prompts.Cli + --configuration Release --no-restore -- + release-check --base "${{ steps.release.outputs.base }}" + --impact "${{ steps.release.outputs.impact }}" diff --git a/.github/workflows/validate-prompts.yml b/.github/workflows/validate-prompts.yml index 7c21ba1..2a556c6 100644 --- a/.github/workflows/validate-prompts.yml +++ b/.github/workflows/validate-prompts.yml @@ -2,6 +2,9 @@ name: validate-prompts on: pull_request: + push: + branches: + - main workflow_call: workflow_dispatch: @@ -42,6 +45,11 @@ jobs: - name: Validate repository contract with CLI run: dotnet run --project tools/DevComputaria.Prompts.Cli --configuration Release --no-build -- validate + - name: Test PromptKit security and runtime contracts + run: >- + dotnet test tests/DevComputaria.PromptKit.Tests/DevComputaria.PromptKit.Tests.csproj + --configuration Release --no-build --no-restore + - name: Test manifest and packed-resource consistency run: >- dotnet test tests/DevComputaria.Prompts.Tests/DevComputaria.Prompts.Tests.csproj diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b0f04..f744868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,10 +20,13 @@ The format is inspired by Keep a Changelog and adapted to the execution model of - Explicitly versioned skills that attach system instructions and provider-neutral tools to `RenderedPrompt`. - Incremental `PromptPins` source generator backed by the versions published in `prompts/catalog.yaml`. - `image-analysis.analyze-document@1.1.0` and `image-analysis.document-extractor@1.0.0` packed artifacts. - -### Planned - -- GH-015 — Security, authorization and release governance. +- Public `PromptLogRedactor` with conservative sensitive-variable masking and DI registration. +- Security and release governance policy covering roles, access, logging, compliance, and rollback. +- CLI `release-check` command for diff-based SemVer classification and artifact immutability enforcement. +- Pull-request release-governance workflow and governed package-version inputs for pack workflows. +- Security reporting policy and pull-request security/SemVer checklist. +- Unit-test and contract validation on pull requests and pushes to `main`, reused as a mandatory gate before package creation. +- Governed publication of validated PromptKit and Prompts packages to the GitHub Packages NuGet registry. ## [2026-08-30] @@ -138,6 +141,9 @@ The format is inspired by Keep a Changelog and adapted to the execution model of - The prompts packaging workflow now requires the reusable contract gate before producing a release artifact. - The prompt validation workflow now runs the end-to-end sample with Production directory-override suppression. - The end-to-end sample now consumes the generated v1.1 prompt pin and prints attached tools. +- Prompt contract validation now runs the PromptKit security/runtime test suite. +- PromptKit packaging now requires the reusable contract gate and emits a versioned workflow artifact. +- Both package workflows verify the declared impact and concrete package version transition before packing. ### Verified @@ -156,6 +162,7 @@ The format is inspired by Keep a Changelog and adapted to the execution model of - GH-012 completed: pull-request validation and package artifact creation are blocked by the prompt contract gate. - GH-013 completed: the sample renders the pinned Image Analysis prompt and invokes a fake client without HTTP. - GH-014 completed: external Markdown parts, schema injection, versioned skill attachment, tools, and generated prompt pins are covered end to end. +- GH-015 completed: authorization roles, log redaction, release checklist, and SemVer governance are documented and enforced by tests/workflows. - Full solution build succeeded in Release configuration with 0 errors and 0 warnings. - PromptKit test suite passed for core abstraction contracts. - PromptKit test suite passed expanded lookup/error coverage for the versioned catalog contract. @@ -172,4 +179,4 @@ The format is inspired by Keep a Changelog and adapted to the execution model of - Production contract remains domain-agnostic and provider-agnostic in `DevComputaria.PromptKit`. - Current implementation intentionally avoids provider SDKs and HTTP dependencies. -- The next execution target in sequence is `GH-014`. +- GH-000 through GH-015 are complete in the task board. diff --git a/README.md b/README.md index dbc0b38..a34651f 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ DevComputaria.Prompting/ └── .github/ └── workflows/ ├── validate-prompts.yml + ├── release-governance.yml ├── pack-promptkit.yml └── pack-prompts.yml ``` @@ -226,7 +227,9 @@ Isso implica: - testes de empacotamento em `DevComputaria.Prompts.Tests`; - testes de contrato em `DevComputaria.Prompts.Contract.Tests`; - validação de consistência de `catalog.yaml`; -- observabilidade via `prompt.id`, `prompt.version` e `prompt.sha256`. +- observabilidade via `prompt.id`, `prompt.version` e `prompt.sha256`; +- redaction explícita de argumentos sensíveis antes de qualquer logging; +- governança verificável de SemVer, imutabilidade e criação de pacotes. Os testes de contrato são parte da governança de publish: se o contrato quebra, o pacote não deveria seguir adiante. @@ -249,6 +252,8 @@ Os testes de contrato são parte da governança de publish: se o contrato quebra ### Histórico - `CHANGELOG.md` +- `SECURITY.md` +- `docs/SECURITY-AND-RELEASE-GOVERNANCE.md` ## Resumo diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e12c996 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security policy + +## Reporting a vulnerability + +Report vulnerabilities through GitHub private vulnerability reporting for this repository. +Do not open a public issue containing secrets, prompt payloads, customer documents, exploit +details, or package credentials. + +Include the affected component/version, reproduction steps, expected impact, and any known +mitigation. A security reviewer will acknowledge the report, coordinate remediation on a +private branch, and disclose details only after a fixed package is available. + +If private vulnerability reporting is unavailable, contact an organization owner through a +private channel and reference this repository without including sensitive payloads in the +initial message. + +## Supported versions + +Security fixes target the latest supported package line. Older prompt pins remain available +for deterministic rollback, but consumers should upgrade the runtime/package when a security +advisory marks an earlier version unsupported. + +See [security and release governance](docs/SECURITY-AND-RELEASE-GOVERNANCE.md) for roles, +logging controls, approvals, and the release checklist. + diff --git a/docs/INDEX.md b/docs/INDEX.md index a4f0224..ba40bff 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -19,3 +19,4 @@ ## Conventions - [Prompt catalog conventions](./CONVENTIONS-prompt-catalog.md) +- [Security and release governance](./SECURITY-AND-RELEASE-GOVERNANCE.md) diff --git a/docs/SECURITY-AND-RELEASE-GOVERNANCE.md b/docs/SECURITY-AND-RELEASE-GOVERNANCE.md new file mode 100644 index 0000000..9da3470 --- /dev/null +++ b/docs/SECURITY-AND-RELEASE-GOVERNANCE.md @@ -0,0 +1,135 @@ +# Security, authorization, and release governance + +This policy is normative for `DevComputaria.PromptKit` and `DevComputaria.Prompts`. +It translates ADR-001/ADR-002 into review, access, logging, and release controls. + +## Roles and authorization + +| Role | Responsibilities | Allowed actions | Restrictions | +|---|---|---|---| +| Contributor | Propose implementation, prompt, skill, schema, test, and documentation changes | Push feature branches and open pull requests | Cannot bypass required checks, approve their own protected change, create production tags, or access package credentials | +| Maintainer | Preserve architecture and catalog integrity | Review and merge approved pull requests; maintain schemas, runtime, catalog, and CI | No direct push to `main`; cannot bypass contract or governance gates | +| Security reviewer | Review sandbox, redaction, logging, secrets, permissions, and supply-chain changes | Approve security-sensitive pull requests and incident remediations | Does not receive package publishing credentials solely by holding this role | +| Release manager | Verify the release checklist, choose the SemVer impact, create protected tags, and promote validated artifacts | Run governed pack/publish workflows through the protected release environment | Cannot publish an unreviewed commit or bypass an unsuccessful gate | + +Organization administrators map these roles to GitHub teams. Membership is reviewed +quarterly and removed immediately when no longer required. Repository administration, +branch-protection bypass, and package publishing remain separate privileges. + +## Repository controls + +Configure `main` with all of the following controls: + +- disallow force pushes, deletion, and direct pushes; +- require pull requests with stale approvals dismissed after new commits; +- require at least one maintainer approval; +- require a security-reviewer approval when changes touch sandboxing, redaction, + authentication, workflow permissions, secrets, or release policy; +- require `validate-prompts / prompt-contract` and + `release-governance / semver-and-immutability` checks; +- require exactly one `semver:patch`, `semver:minor`, or `semver:major` label; +- restrict tag creation matching `v*` and the production package environment to + release managers; +- use least-privilege workflow permissions and environment-scoped package credentials. + +The role names are stable policy identifiers. Team names and membership are managed at +organization level so personnel are not hard-coded into this repository. + +## Logging and redaction + +Allowed production telemetry is limited to contract metadata such as `prompt.id`, +`prompt.version`, `prompt.sha256`, package version, duration, status, and provider-neutral +diagnostics. + +Raw `PromptArgs`, rendered messages, OCR text, documents, schema payloads, tool arguments, +and model responses must not be logged by default. Code that needs argument-level diagnostic +fields must call `PromptLogRedactor.RedactArguments` and log only its returned dictionary. + +The redactor applies these rules: + +- a variable with `redacted_in_logs: true` becomes `[REDACTED]` without invoking + `ToString()` or otherwise materializing its value; +- declared non-sensitive variables may retain their value; +- undeclared arguments and missing variables are omitted; +- callers must not append the original argument or rendered content to the same log event. + +Security tests verify masking, omission of undeclared values, non-materialization of secret +objects, and DI registration. Any change to this policy requires security-reviewer approval. + +## SemVer policy enforced by tooling + +Every pull request declares one impact label. The `release-check` command compares the pull +request with its base ref and computes the minimum impact: + +| Change | Minimum impact | +|---|---| +| Documentation, tests, or CI-only change | PATCH | +| New prompt/skill PATCH version | PATCH | +| New prompt/skill ID or MINOR version; compatible runtime addition | MINOR | +| Prompt/skill MAJOR version; output schema revision; public schema change | MAJOR | +| Removal of a pin-able artifact or runtime source file | MAJOR | +| Modification of an existing versioned prompt, skill, Markdown part, or output schema | Forbidden; create a new version | + +A higher declared impact is allowed. During package creation, the command additionally checks +that `previous-version -> package-version` exactly matches the declared impact. Versions use +the `X.Y.Z` form. + +Local example: + +```bash +dotnet run --project tools/DevComputaria.Prompts.Cli -- \ + release-check --base origin/main --impact minor +``` + +Release example: + +```bash +dotnet run --project tools/DevComputaria.Prompts.Cli -- \ + release-check --base v1.2.3 --impact minor \ + --previous-version 1.2.3 --next-version 1.3.0 +``` + +## Release checklist + +### Pull request and compliance + +- [ ] Change is reviewed through a pull request; no direct `main` update. +- [ ] Exactly one `semver:*` label matches the expected compatibility impact. +- [ ] Published prompt, skill, Markdown part, and output-schema bytes are unchanged. +- [ ] New artifact paths and manifest pins are explicit and immutable. +- [ ] Sensitive variables declare `redacted_in_logs: true`. +- [ ] No raw prompt arguments, messages, documents, or responses are added to logs. +- [ ] Security-sensitive changes have security-reviewer approval. +- [ ] Contract and release-governance checks pass. + +### Package creation and promotion + +- [ ] Release manager identifies the previous protected tag and package version. +- [ ] Proposed `X.Y.Z` package version exactly matches the declared impact. +- [ ] Changelog documents additions, breaking changes, and removed pins. +- [ ] Governed pack workflow succeeds for the exact reviewed commit SHA. +- [ ] The governed workflow uploads the `.nupkg` as an audit artifact and publishes + that same file to GitHub Packages; it is not rebuilt locally. +- [ ] Package metadata and SHA-256 are recorded in the release notes. +- [ ] Publishing occurs only through the protected `production` environment. +- [ ] Publication uses the workflow-scoped `GITHUB_TOKEN` with `packages: write`; + credentials are short-lived and absent from logs/artifacts. +- [ ] Protected `vX.Y.Z` tag points to the packaged commit. + +### After release + +- [ ] Restore/install smoke test succeeds from the package registry. +- [ ] Previous package and prompt pins remain available for rollback unless a documented + MAJOR removal was approved. +- [ ] Release notes identify rollback package and prompt pins. +- [ ] Any anomaly or suspected leak follows `SECURITY.md` reporting instructions. + +## Verifiable evidence + +- Redaction: `PromptLogRedactorTests` and DI coverage in `ServiceRegistrationTests`. +- SemVer and immutability: `ReleaseGovernanceValidatorTests`. +- Pull-request enforcement: `.github/workflows/release-governance.yml`. +- Contract enforcement: `.github/workflows/validate-prompts.yml`. +- Package version enforcement and GitHub Packages publication: `pack-promptkit.yml` + and `pack-prompts.yml`. + diff --git a/docs/task/INDEX.md b/docs/task/INDEX.md index cae1a14..cfd9151 100644 --- a/docs/task/INDEX.md +++ b/docs/task/INDEX.md @@ -35,7 +35,7 @@ Este índice organiza as atividades do PRD (`GH-001`..`GH-015`) em tarefas execu | GH-012 | Implement CI publish gates | PR-2 | F3 | GH-010, GH-011 | done | 2026-10-10 | [TASK-012](./TASK-012-implement-ci-publish-gates.md) | Gate reutilizável bloqueia pack em falha de contrato | | GH-013 | Deliver end-to-end sample | PR-2 | F4 | GH-007, GH-009 | done | 2026-10-10 | [TASK-013](./TASK-013-deliver-end-to-end-sample.md) | Render pinado e client fake sem HTTP | | GH-014 | Implement extension set v1.1 | PR-2 | F5 | GH-010, GH-013 | done | 2026-10-10 | [TASK-014](./TASK-014-implement-extension-set-v1-1.md) | `from`, schema injection, skills/tools e pins gerados | -| GH-015 | Security, authorization and release governance | PR-2 | F3-F5 | GH-012 | todo | 2026-08-30 | [TASK-015](./TASK-015-security-authorization-release-governance.md) | Governança final | +| GH-015 | Security, authorization and release governance | PR-2 | F3-F5 | GH-012 | done | 2026-10-10 | [TASK-015](./TASK-015-security-authorization-release-governance.md) | Papéis, redaction e gate SemVer verificável | ## Change log @@ -56,3 +56,4 @@ Este índice organiza as atividades do PRD (`GH-001`..`GH-015`) em tarefas execu - 2026-10-10: Execução da `GH-012` concluída (gate de contrato reutilizável e pack condicionado ao sucesso da validação). - 2026-10-10: Execução da `GH-013` concluída (sample end-to-end com render pinado, metadados e client fake). - 2026-10-10: Execução da `GH-014` concluída (`parts[].from`, schema injection, skills/tools pinadas e `PromptPins` gerados). +- 2026-10-10: Execução da `GH-015` concluída (papéis e autorização, redaction segura, checklist e gates SemVer/release). diff --git a/docs/task/TASK-015-security-authorization-release-governance.md b/docs/task/TASK-015-security-authorization-release-governance.md index adfc6f8..6dd6600 100644 --- a/docs/task/TASK-015-security-authorization-release-governance.md +++ b/docs/task/TASK-015-security-authorization-release-governance.md @@ -1,6 +1,6 @@ # GH-015 — Security, authorization and release governance -- **Status:** todo +- **Status:** done - **PR bucket:** PR-2 - **Phase:** F3-F5 - **Source:** PRD 10.15, ADR-001 section 6, ADR-002 section 2 @@ -26,6 +26,19 @@ Formalizar políticas de segurança, acesso e governança de release para opera ## Acceptance criteria -- [ ] Papéis e responsabilidades publicados. -- [ ] Redaction validada por testes. -- [ ] Processo de versionamento aplicado de forma verificável. +- [x] Papéis e responsabilidades publicados. +- [x] Redaction validada por testes. +- [x] Processo de versionamento aplicado de forma verificável. + +## Implementation notes + +- `docs/SECURITY-AND-RELEASE-GOVERNANCE.md` publica papéis, matriz de autorização, + política de logging, controles do repositório e checklist de release/compliance. +- `PromptLogRedactor` mascara variáveis `redacted_in_logs`, omite argumentos não + declarados e está registrado no bootstrap de DI. +- `release-check` calcula o impacto mínimo do diff, impede mutação retroativa de + artefatos e valida a transição concreta `X.Y.Z` do pacote. +- `release-governance.yml` exige um único label `semver:*`; os workflows de pack + validam base, impacto e versões antes de criar o artefato. +- A evidência automatizada está em `PromptLogRedactorTests`, + `ReleaseGovernanceValidatorTests` e nos gates de CI. diff --git a/src/DevComputaria.PromptKit/Hosting/PromptKitServiceCollectionExtensions.cs b/src/DevComputaria.PromptKit/Hosting/PromptKitServiceCollectionExtensions.cs index 7d35848..5333aa5 100644 --- a/src/DevComputaria.PromptKit/Hosting/PromptKitServiceCollectionExtensions.cs +++ b/src/DevComputaria.PromptKit/Hosting/PromptKitServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using DevComputaria.PromptKit.Abstractions; using DevComputaria.PromptKit.Composition; using DevComputaria.PromptKit.Hashing; +using DevComputaria.PromptKit.Observability; using DevComputaria.PromptKit.Rendering; using DevComputaria.PromptKit.Validation; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +22,7 @@ public static IServiceCollection AddPromptKit(this IServiceCollection services, services.TryAddSingleton>(_ => Options.Create(options)); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -57,4 +59,4 @@ private static PromptKitOptions Normalize(PromptKitOptions options) private static bool IsDevelopment(string? environmentName) => string.Equals(environmentName?.Trim(), "Development", StringComparison.OrdinalIgnoreCase); -} \ No newline at end of file +} diff --git a/src/DevComputaria.PromptKit/Observability/PromptLogRedactor.cs b/src/DevComputaria.PromptKit/Observability/PromptLogRedactor.cs new file mode 100644 index 0000000..49cf91c --- /dev/null +++ b/src/DevComputaria.PromptKit/Observability/PromptLogRedactor.cs @@ -0,0 +1,28 @@ +using System.Collections.ObjectModel; +using DevComputaria.PromptKit.Abstractions; + +namespace DevComputaria.PromptKit.Observability; + +public sealed class PromptLogRedactor +{ + public const string RedactedValue = "[REDACTED]"; + + public IReadOnlyDictionary RedactArguments(PromptSpec prompt, PromptArgs args) + { + ArgumentNullException.ThrowIfNull(prompt); + ArgumentNullException.ThrowIfNull(args); + + var safeValues = new Dictionary(StringComparer.Ordinal); + foreach (var variable in prompt.Variables.Values.OrderBy(variable => variable.Name, StringComparer.Ordinal)) + { + if (!args.TryGetValue(variable.Name, out var value)) + { + continue; + } + + safeValues[variable.Name] = variable.RedactedInLogs ? RedactedValue : value; + } + + return new ReadOnlyDictionary(safeValues); + } +} diff --git a/src/DevComputaria.PromptKit/README.md b/src/DevComputaria.PromptKit/README.md index 812adc8..8ac2e4d 100644 --- a/src/DevComputaria.PromptKit/README.md +++ b/src/DevComputaria.PromptKit/README.md @@ -1,3 +1,6 @@ # DevComputaria.PromptKit -Runtime prompt engine package (domain-agnostic). +Runtime prompt engine package (domain-agnostic). Consumers that emit argument-level +diagnostics must resolve `PromptLogRedactor` from DI and log only the dictionary +returned by `RedactArguments`; rendered messages and raw `PromptArgs` are not safe +production log payloads. diff --git a/tests/DevComputaria.PromptKit.Tests/PromptLogRedactorTests.cs b/tests/DevComputaria.PromptKit.Tests/PromptLogRedactorTests.cs new file mode 100644 index 0000000..8a5c276 --- /dev/null +++ b/tests/DevComputaria.PromptKit.Tests/PromptLogRedactorTests.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using DevComputaria.PromptKit.Abstractions; +using DevComputaria.PromptKit.Observability; +using Xunit; + +namespace DevComputaria.PromptKit.Tests; + +public sealed class PromptLogRedactorTests +{ + [Fact] + public void RedactArguments_ShouldMaskSensitiveVariablesWithoutReadingTheirValue() + { + const string secret = "RG-123456-SENSITIVE"; + var prompt = CreatePrompt(); + var args = new PromptArgs(new Dictionary + { + ["country"] = "BR", + ["ocr_text"] = new SecretValue(secret) + }); + + var safe = new PromptLogRedactor().RedactArguments(prompt, args); + var serialized = JsonSerializer.Serialize(safe); + + Assert.Equal("BR", safe["country"]); + Assert.Equal(PromptLogRedactor.RedactedValue, safe["ocr_text"]); + Assert.DoesNotContain(secret, serialized, StringComparison.Ordinal); + Assert.Equal(0, SecretValue.ToStringCalls); + } + + [Fact] + public void RedactArguments_ShouldOmitUndeclaredArgumentsAndMissingVariables() + { + var prompt = CreatePrompt(); + var args = new PromptArgs(new Dictionary + { + ["country"] = "BR", + ["unexpected_token"] = "must-not-be-logged" + }); + + var safe = new PromptLogRedactor().RedactArguments(prompt, args); + + Assert.Single(safe); + Assert.Equal("BR", safe["country"]); + Assert.False(safe.ContainsKey("unexpected_token")); + Assert.False(safe.ContainsKey("ocr_text")); + } + + private static PromptSpec CreatePrompt() + => new( + new PromptId("image-analysis.analyze-document", "1.1.0"), + new[] { new RenderedMessage("user", "{{ocr_text}}") }, + new[] + { + new PromptVariableSpec("country", isRequired: true, type: "string"), + new PromptVariableSpec("ocr_text", isRequired: true, redactedInLogs: true, type: "string") + }); + + private sealed class SecretValue + { + public SecretValue(string value) + { + Value = value; + ToStringCalls = 0; + } + + public static int ToStringCalls { get; private set; } + + private string Value { get; } + + public override string ToString() + { + ToStringCalls++; + return Value; + } + } +} diff --git a/tests/DevComputaria.Prompts.Cli.Tests/CliApplicationTests.cs b/tests/DevComputaria.Prompts.Cli.Tests/CliApplicationTests.cs index 454e762..b41b184 100644 --- a/tests/DevComputaria.Prompts.Cli.Tests/CliApplicationTests.cs +++ b/tests/DevComputaria.Prompts.Cli.Tests/CliApplicationTests.cs @@ -84,6 +84,20 @@ public async Task Render_MissingRequiredArgument_ReturnsOneWithDiagnostic() Assert.Contains("ocr_text", error.ToString(), StringComparison.Ordinal); } + [Fact] + public async Task ReleaseCheck_Help_ShouldDescribeGovernanceContract() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var application = new CliApplication(output, error); + + var exitCode = await application.RunAsync(["release-check", "--help"]); + + Assert.Equal(0, exitCode); + Assert.Contains("--impact ", output.ToString(), StringComparison.Ordinal); + Assert.Equal(string.Empty, error.ToString()); + } + private static string RepositoryRoot { get; } = FindRepositoryRoot(); private static string FindRepositoryRoot() diff --git a/tests/DevComputaria.Prompts.Cli.Tests/ReleaseGovernanceValidatorTests.cs b/tests/DevComputaria.Prompts.Cli.Tests/ReleaseGovernanceValidatorTests.cs new file mode 100644 index 0000000..c68a85b --- /dev/null +++ b/tests/DevComputaria.Prompts.Cli.Tests/ReleaseGovernanceValidatorTests.cs @@ -0,0 +1,94 @@ +using Xunit; + +namespace DevComputaria.Prompts.Cli.Tests; + +public sealed class ReleaseGovernanceValidatorTests +{ + private readonly ReleaseGovernanceValidator _validator = new(); + + [Fact] + public void Validate_NewMinorPromptVersion_ShouldRequireMinorImpact() + { + var result = _validator.Validate( + [new RepositoryChange("A", "prompts/image-analysis/analyze-document/1.1.0.yaml")], + ["prompts/image-analysis/analyze-document/1.0.0.yaml"], + SemVersionImpact.Minor); + + Assert.True(result.IsValid); + Assert.Equal(SemVersionImpact.Minor, result.RequiredImpact); + } + + [Fact] + public void Validate_ModifiedPublishedArtifact_ShouldFailRegardlessOfDeclaredImpact() + { + const string path = "skills/image-analysis/document-extractor/1.0.0.yaml"; + + var result = _validator.Validate( + [new RepositoryChange("M", path)], + [path], + SemVersionImpact.Major); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Contains("cannot be modified in place", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_RemovedPinnedVersion_ShouldRequireMajorImpact() + { + const string path = "prompts/image-analysis/analyze-document/1.0.0.yaml"; + + var result = _validator.Validate( + [new RepositoryChange("D", path)], + [path], + SemVersionImpact.Minor); + + Assert.False(result.IsValid); + Assert.Equal(SemVersionImpact.Major, result.RequiredImpact); + Assert.Contains(result.Issues, issue => issue.Contains("lower than required MAJOR", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_NewOutputSchemaRevision_ShouldRequireMajorImpact() + { + var result = _validator.Validate( + [new RepositoryChange("A", "schemas/output/image-analysis-document-v2.json")], + ["schemas/output/image-analysis-document-v1.json"], + SemVersionImpact.Major); + + Assert.True(result.IsValid); + Assert.Equal(SemVersionImpact.Major, result.RequiredImpact); + } + + [Theory] + [InlineData("1.2.3", "1.2.4", SemVersionImpact.Patch)] + [InlineData("1.2.3", "1.3.0", SemVersionImpact.Minor)] + [InlineData("1.2.3", "2.0.0", SemVersionImpact.Major)] + public void Validate_PackageVersionTransition_ShouldMatchDeclaredImpact( + string previous, + string next, + SemVersionImpact impact) + { + var result = _validator.Validate( + [new RepositoryChange("M", "README.md")], + Array.Empty(), + impact, + previous, + next); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_MismatchedPackageVersionTransition_ShouldFail() + { + var result = _validator.Validate( + [new RepositoryChange("M", "src/DevComputaria.PromptKit/Observability/PromptLogRedactor.cs")], + Array.Empty(), + SemVersionImpact.Minor, + "1.2.3", + "1.2.4"); + + Assert.False(result.IsValid); + Assert.Contains(result.Issues, issue => issue.Contains("is PATCH, not declared MINOR", StringComparison.Ordinal)); + } +} diff --git a/tests/DevComputaria.Prompts.Tests/ServiceRegistrationTests.cs b/tests/DevComputaria.Prompts.Tests/ServiceRegistrationTests.cs index 8159d3f..692db82 100644 --- a/tests/DevComputaria.Prompts.Tests/ServiceRegistrationTests.cs +++ b/tests/DevComputaria.Prompts.Tests/ServiceRegistrationTests.cs @@ -1,6 +1,7 @@ using DevComputaria.PromptKit.Abstractions; using DevComputaria.PromptKit.Catalogs; using DevComputaria.PromptKit.Hosting; +using DevComputaria.PromptKit.Observability; using DevComputaria.Prompts.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -27,6 +28,7 @@ public async Task AddPromptKit_ShouldRegisterCoreRuntimeServices() Assert.NotNull(provider.GetRequiredService()); Assert.NotNull(provider.GetRequiredService()); Assert.NotNull(provider.GetRequiredService()); + Assert.NotNull(provider.GetRequiredService()); var renderer = provider.GetRequiredService(); var rendered = await renderer.RenderAsync(prompt.Id, new PromptArgs(new Dictionary @@ -86,4 +88,4 @@ public void AddPackedPrompts_ShouldIgnoreDirectoryOverrideOutsideDevelopment() Assert.False(promptKitOptions.AllowDirectoryOverride); Assert.Null(promptKitOptions.DirectoryOverridePath); } -} \ No newline at end of file +} diff --git a/tools/DevComputaria.Prompts.Cli/CliApplication.cs b/tools/DevComputaria.Prompts.Cli/CliApplication.cs index 4d6cba1..7d5db9f 100644 --- a/tools/DevComputaria.Prompts.Cli/CliApplication.cs +++ b/tools/DevComputaria.Prompts.Cli/CliApplication.cs @@ -35,6 +35,7 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok { "validate" => await ValidateAsync(args[1..]), "render" => await RenderAsync(args[1..], cancellationToken), + "release-check" => await ReleaseCheckAsync(args[1..]), _ => await FailAsync($"Unknown command '{args[0]}'.{Environment.NewLine}{Usage}") }; } @@ -140,6 +141,67 @@ private async Task RenderAsync(string[] args, CancellationToken cancellatio return 0; } + private async Task ReleaseCheckAsync(string[] args) + { + if (args.Length == 0 || IsHelp(args[0])) + { + await _output.WriteLineAsync(ReleaseCheckUsage); + return args.Length == 0 ? 1 : 0; + } + + string? root = null; + string? baseRef = null; + string? impactText = null; + string? previousVersion = null; + string? nextVersion = null; + + for (var index = 0; index < args.Length; index++) + { + var option = args[index]; + if (option == "--root" && index + 1 < args.Length) root = args[++index]; + else if (option == "--base" && index + 1 < args.Length) baseRef = args[++index]; + else if (option == "--impact" && index + 1 < args.Length) impactText = args[++index]; + else if (option == "--previous-version" && index + 1 < args.Length) previousVersion = args[++index]; + else if (option == "--next-version" && index + 1 < args.Length) nextVersion = args[++index]; + else throw new ArgumentException($"Unknown or incomplete option '{option}'."); + } + + if (string.IsNullOrWhiteSpace(baseRef) || string.IsNullOrWhiteSpace(impactText)) + { + return await FailAsync("Both --base and --impact are required."); + } + + if ((previousVersion is null) != (nextVersion is null)) + { + return await FailAsync("--previous-version and --next-version must be supplied together."); + } + + var repositoryRoot = RepositoryPaths.Resolve(root); + var (changes, baseArtifactPaths) = new GitRepositoryInspector().Compare(repositoryRoot, baseRef); + var declaredImpact = ReleaseGovernanceValidator.ParseImpact(impactText); + var result = new ReleaseGovernanceValidator().Validate( + changes, + baseArtifactPaths, + declaredImpact, + previousVersion, + nextVersion); + + await _output.WriteLineAsync($"Required SemVer impact: {result.RequiredImpact.ToString().ToUpperInvariant()}"); + if (!result.IsValid) + { + await _error.WriteLineAsync($"Release governance failed with {result.Issues.Count} error(s):"); + foreach (var issue in result.Issues) + { + await _error.WriteLineAsync($"- {issue}"); + } + + return 1; + } + + await _output.WriteLineAsync($"Release governance succeeded with declared {declaredImpact.ToString().ToUpperInvariant()} impact."); + return 0; + } + private static IReadOnlyDictionary ReadArguments(string path) { using var document = JsonDocument.Parse(File.ReadAllText(path)); @@ -212,6 +274,7 @@ private sealed record CommandOptions(string? Root, string? ArgsFile, IReadOnlyLi Commands: validate [--root ] Validate schemas, manifest, aliases and includes. render @ [--args ] [--arg key=value] + release-check --base --impact [version options] Run ' --help' for command details. """; @@ -229,4 +292,13 @@ validate [--root ] Validates schemas, manifest/resources, aliases, identities, includes and output schemas. """; + + private const string ReleaseCheckUsage = """ + Usage: + release-check --base --impact [--root ] + [--previous-version --next-version ] + + Computes the minimum release impact from the Git diff, rejects in-place mutation of + published artifacts, and optionally verifies the concrete package version transition. + """; } diff --git a/tools/DevComputaria.Prompts.Cli/GitRepositoryInspector.cs b/tools/DevComputaria.Prompts.Cli/GitRepositoryInspector.cs new file mode 100644 index 0000000..e4f9c8a --- /dev/null +++ b/tools/DevComputaria.Prompts.Cli/GitRepositoryInspector.cs @@ -0,0 +1,79 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace DevComputaria.Prompts.Cli; + +internal sealed partial class GitRepositoryInspector +{ + public (IReadOnlyList Changes, IReadOnlyList BaseArtifactPaths) Compare(string root, string baseRef) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + ArgumentException.ThrowIfNullOrWhiteSpace(baseRef); + + if (!SafeRefRegex().IsMatch(baseRef)) + { + throw new ArgumentException($"Git base ref '{baseRef}' contains unsupported characters.", nameof(baseRef)); + } + + var diff = RunGit(root, "diff", "--name-status", "--find-renames", $"{baseRef}...HEAD", "--"); + var tree = RunGit(root, "ls-tree", "-r", "--name-only", baseRef, "--", "prompts", "skills", "schemas/output"); + + return (ParseChanges(diff), SplitLines(tree)); + } + + private static IReadOnlyList ParseChanges(string output) + { + var changes = new List(); + foreach (var line in SplitLines(output)) + { + var fields = line.Split('\t'); + if (fields.Length == 2) + { + changes.Add(new RepositoryChange(fields[0], fields[1])); + } + else if (fields.Length == 3 && fields[0].StartsWith('R')) + { + changes.Add(new RepositoryChange(fields[0], fields[2], fields[1])); + } + else + { + throw new InvalidOperationException($"Unsupported git diff entry: '{line}'."); + } + } + + return changes; + } + + private static string RunGit(string root, params string[] arguments) + { + var startInfo = new ProcessStartInfo("git") + { + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start git."); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"git {arguments[0]} failed: {error.Trim()}"); + } + + return output; + } + + private static IReadOnlyList SplitLines(string value) + => value.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + [GeneratedRegex("^[A-Za-z0-9._/-]+$", RegexOptions.CultureInvariant)] + private static partial Regex SafeRefRegex(); +} diff --git a/tools/DevComputaria.Prompts.Cli/README.md b/tools/DevComputaria.Prompts.Cli/README.md index 8a73cfb..3144918 100644 --- a/tools/DevComputaria.Prompts.Cli/README.md +++ b/tools/DevComputaria.Prompts.Cli/README.md @@ -29,8 +29,21 @@ Também é possível fornecer um objeto JSON com `--args caminho/args.json`. Opções `--arg key=value` podem ser repetidas e sobrescrevem valores do arquivo. O resultado de `render` é JSON com mensagens, hash e metadados. +Para validar o impacto SemVer e a imutabilidade antes do PR: + +```bash +dotnet run --project tools/DevComputaria.Prompts.Cli -- release-check \ + --base origin/main \ + --impact minor +``` + +Durante um release, informe também `--previous-version X.Y.Z` e +`--next-version X.Y.Z`. O comando falha quando o impacto declarado é menor que +o diff exige, quando a transição concreta não corresponde ao impacto ou quando +um artefato versionado foi alterado no mesmo path. + ## Exit codes - `0`: comando concluído com sucesso; -- `1`: erro de uso, validação ou renderização. O diagnóstico é escrito em +- `1`: erro de uso, validação, governança ou renderização. O diagnóstico é escrito em `stderr`. diff --git a/tools/DevComputaria.Prompts.Cli/ReleaseGovernanceValidator.cs b/tools/DevComputaria.Prompts.Cli/ReleaseGovernanceValidator.cs new file mode 100644 index 0000000..236cc0b --- /dev/null +++ b/tools/DevComputaria.Prompts.Cli/ReleaseGovernanceValidator.cs @@ -0,0 +1,284 @@ +using System.Text.RegularExpressions; + +namespace DevComputaria.Prompts.Cli; + +public enum SemVersionImpact +{ + None = 0, + Patch = 1, + Minor = 2, + Major = 3 +} + +public sealed record RepositoryChange(string Status, string Path, string? PreviousPath = null); + +public sealed record ReleaseGovernanceResult(SemVersionImpact RequiredImpact, IReadOnlyList Issues) +{ + public bool IsValid => Issues.Count == 0; +} + +public sealed partial class ReleaseGovernanceValidator +{ + public ReleaseGovernanceResult Validate( + IEnumerable changes, + IEnumerable baseArtifactPaths, + SemVersionImpact declaredImpact, + string? previousPackageVersion = null, + string? nextPackageVersion = null) + { + ArgumentNullException.ThrowIfNull(changes); + ArgumentNullException.ThrowIfNull(baseArtifactPaths); + + if (declaredImpact is SemVersionImpact.None) + { + throw new ArgumentException("A PATCH, MINOR, or MAJOR impact must be declared.", nameof(declaredImpact)); + } + + var issues = new List(); + var required = SemVersionImpact.None; + var baseArtifacts = baseArtifactPaths + .Select(NormalizePath) + .Select(ParseArtifact) + .Where(artifact => artifact is not null) + .Cast() + .ToArray(); + + foreach (var change in ExpandRenames(changes)) + { + var path = NormalizePath(change.Path); + var artifact = ParseArtifact(path); + + if (artifact is not null) + { + switch (change.Status) + { + case "M": + issues.Add($"Published artifact '{path}' cannot be modified in place; add a new versioned path."); + break; + case "D": + required = Max(required, SemVersionImpact.Major); + break; + case "A": + required = Max(required, ClassifyAddition(artifact, baseArtifacts, issues)); + break; + } + + continue; + } + + required = Max(required, ClassifyRepositoryChange(change.Status, path)); + } + + if (required is SemVersionImpact.None) + { + required = SemVersionImpact.Patch; + } + + if (declaredImpact < required) + { + issues.Add($"Declared {declaredImpact.ToString().ToUpperInvariant()} impact is lower than required {required.ToString().ToUpperInvariant()} impact."); + } + + ValidatePackageVersions(previousPackageVersion, nextPackageVersion, declaredImpact, issues); + return new ReleaseGovernanceResult(required, issues.AsReadOnly()); + } + + public static SemVersionImpact ParseImpact(string value) + => value.Trim().ToLowerInvariant() switch + { + "patch" => SemVersionImpact.Patch, + "minor" => SemVersionImpact.Minor, + "major" => SemVersionImpact.Major, + _ => throw new ArgumentException($"Unknown SemVer impact '{value}'. Expected patch, minor, or major.", nameof(value)) + }; + + private static IEnumerable ExpandRenames(IEnumerable changes) + { + foreach (var change in changes) + { + if (change.Status.StartsWith('R') && change.PreviousPath is not null) + { + yield return new RepositoryChange("D", change.PreviousPath); + yield return new RepositoryChange("A", change.Path); + } + else + { + yield return change with { Status = change.Status[..1].ToUpperInvariant() }; + } + } + } + + private static SemVersionImpact ClassifyAddition( + VersionedArtifact artifact, + IReadOnlyList baseArtifacts, + ICollection issues) + { + var previousVersions = baseArtifacts + .Where(candidate => candidate.Key.Equals(artifact.Key, StringComparison.Ordinal)) + .Select(candidate => candidate.Version) + .Distinct() + .OrderByDescending(version => version) + .ToArray(); + + if (previousVersions.Length == 0) + { + return SemVersionImpact.Minor; + } + + if (previousVersions.Contains(artifact.Version)) + { + issues.Add($"Artifact component '{artifact.Path}' was added to already published version '{artifact.Version}'. Create a new version instead."); + return SemVersionImpact.Patch; + } + + var previous = previousVersions[0]; + if (artifact.Version <= previous) + { + issues.Add($"Artifact '{artifact.Path}' must advance beyond published version '{previous}'."); + return SemVersionImpact.Patch; + } + + if (artifact.Kind == "schema") + { + return SemVersionImpact.Major; + } + + if (artifact.Version.Major > previous.Major) + { + return SemVersionImpact.Major; + } + + return artifact.Version.Minor > previous.Minor + ? SemVersionImpact.Minor + : SemVersionImpact.Patch; + } + + private static SemVersionImpact ClassifyRepositoryChange(string status, string path) + { + if (status == "D" && path.StartsWith("src/", StringComparison.Ordinal)) + { + return SemVersionImpact.Major; + } + + if (path is "schemas/prompt.schema.json" or "schemas/catalog.schema.json") + { + return SemVersionImpact.Major; + } + + if (path.StartsWith("src/", StringComparison.Ordinal)) + { + return SemVersionImpact.Minor; + } + + return SemVersionImpact.Patch; + } + + private static void ValidatePackageVersions( + string? previousPackageVersion, + string? nextPackageVersion, + SemVersionImpact declaredImpact, + ICollection issues) + { + if (previousPackageVersion is null && nextPackageVersion is null) + { + return; + } + + if (!SemanticVersion.TryParse(previousPackageVersion, out var previous) || + !SemanticVersion.TryParse(nextPackageVersion, out var next)) + { + issues.Add("Both --previous-version and --next-version must use the X.Y.Z format."); + return; + } + + var actualImpact = next.Major > previous.Major + ? SemVersionImpact.Major + : next.Major == previous.Major && next.Minor > previous.Minor + ? SemVersionImpact.Minor + : next.Major == previous.Major && next.Minor == previous.Minor && next.Patch > previous.Patch + ? SemVersionImpact.Patch + : SemVersionImpact.None; + + if (actualImpact is SemVersionImpact.None) + { + issues.Add($"Next package version '{next}' must be greater than previous version '{previous}'."); + } + else if (actualImpact != declaredImpact) + { + issues.Add($"Package version transition '{previous}' -> '{next}' is {actualImpact.ToString().ToUpperInvariant()}, not declared {declaredImpact.ToString().ToUpperInvariant()}."); + } + } + + private static VersionedArtifact? ParseArtifact(string path) + { + var promptOrSkill = PromptOrSkillRegex().Match(path); + if (promptOrSkill.Success && SemanticVersion.TryParse(promptOrSkill.Groups[4].Value, out var version)) + { + return new VersionedArtifact( + path, + $"{promptOrSkill.Groups[1].Value}/{promptOrSkill.Groups[2].Value}/{promptOrSkill.Groups[3].Value}", + promptOrSkill.Groups[1].Value, + version); + } + + var schema = OutputSchemaRegex().Match(path); + if (schema.Success && int.TryParse(schema.Groups[2].Value, out var schemaVersion)) + { + return new VersionedArtifact( + path, + $"schemas/output/{schema.Groups[1].Value}", + "schema", + new SemanticVersion(schemaVersion, 0, 0)); + } + + return null; + } + + private static SemVersionImpact Max(SemVersionImpact left, SemVersionImpact right) + => left >= right ? left : right; + + private static string NormalizePath(string path) => path.Replace('\\', '/').TrimStart('.', '/'); + + [GeneratedRegex("^(prompts|skills)/([^/]+)/([^/]+)/(\\d+\\.\\d+\\.\\d+)(?:\\.[^/]+)?\\.(?:yaml|md)$", RegexOptions.CultureInvariant)] + private static partial Regex PromptOrSkillRegex(); + + [GeneratedRegex("^schemas/output/(.+)-v(\\d+)\\.json$", RegexOptions.CultureInvariant)] + private static partial Regex OutputSchemaRegex(); + + private sealed record VersionedArtifact(string Path, string Key, string Kind, SemanticVersion Version); + + private readonly record struct SemanticVersion(int Major, int Minor, int Patch) : IComparable + { + public static bool TryParse(string? value, out SemanticVersion version) + { + var parts = value?.Split('.'); + if (parts is { Length: 3 } && + int.TryParse(parts[0], out var major) && + int.TryParse(parts[1], out var minor) && + int.TryParse(parts[2], out var patch) && + major >= 0 && minor >= 0 && patch >= 0) + { + version = new SemanticVersion(major, minor, patch); + return true; + } + + version = default; + return false; + } + + public int CompareTo(SemanticVersion other) + { + var major = Major.CompareTo(other.Major); + if (major != 0) return major; + var minor = Minor.CompareTo(other.Minor); + return minor != 0 ? minor : Patch.CompareTo(other.Patch); + } + + public static bool operator >(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) > 0; + public static bool operator <(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) < 0; + public static bool operator >=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) >= 0; + public static bool operator <=(SemanticVersion left, SemanticVersion right) => left.CompareTo(right) <= 0; + + public override string ToString() => $"{Major}.{Minor}.{Patch}"; + } +}