diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 2233dc4..6505933 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -40,18 +40,25 @@ 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`. -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. +`publish-packages.yml` has no version inputs. It starts automatically on every +push to `main`; manual dispatch remains available only to retry an interrupted +release. It finds the latest reachable `vX.Y.Z` tag, calculates the required +impact from the repository diff, derives the next version, and validates that +transition. The first release uses the normalized version from `version.json`; +rerunning an already tagged commit reuses its version safely. + +Release runs are serialized with `cancel-in-progress: false`, so a later commit +cannot cancel or race an in-flight package publication. ## 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. +`publish-packages.yml` calls `pack-promptkit.yml` and `pack-prompts.yml` as internal +reusable workflows only after the prompt contract gate succeeds, including all +four test projects. PromptKit is published first because Prompts depends on the +same package version. A successful run uploads both `.nupkg` files as workflow +artifacts, publishes those files to the repository owner's GitHub Packages NuGet +registry, and creates the corresponding annotated tag. 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` diff --git a/.github/workflows/pack-promptkit.yml b/.github/workflows/pack-promptkit.yml index aac2c70..d47fc21 100644 --- a/.github/workflows/pack-promptkit.yml +++ b/.github/workflows/pack-promptkit.yml @@ -1,35 +1,28 @@ name: pack-promptkit on: - workflow_dispatch: + workflow_call: inputs: base-ref: - description: Previous release tag or Git ref required: true + type: string previous-version: - description: Previously published package version (X.Y.Z) - required: true + required: false + type: string package-version: - description: Package version to create (X.Y.Z) required: true + type: string semver-impact: - description: Declared release impact required: true - type: choice - options: [patch, minor, major] + type: string 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 @@ -56,6 +49,7 @@ jobs: run: dotnet restore tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj - name: Validate release version and impact + if: inputs['previous-version'] != '' run: >- dotnet run --project tools/DevComputaria.Prompts.Cli --configuration Release --no-restore -- @@ -64,6 +58,14 @@ jobs: --previous-version "${{ inputs['previous-version'] }}" --next-version "${{ inputs['package-version'] }}" + - name: Validate initial release impact + if: inputs['previous-version'] == '' + run: >- + dotnet run --project tools/DevComputaria.Prompts.Cli + --configuration Release --no-restore -- + release-check --base "${{ inputs['base-ref'] }}" + --impact "${{ inputs['semver-impact'] }}" + - name: Pack validated runtime run: >- dotnet pack src/DevComputaria.PromptKit/DevComputaria.PromptKit.csproj diff --git a/.github/workflows/pack-prompts.yml b/.github/workflows/pack-prompts.yml index e37d219..0e9d83a 100644 --- a/.github/workflows/pack-prompts.yml +++ b/.github/workflows/pack-prompts.yml @@ -1,35 +1,31 @@ name: pack-prompts on: - workflow_dispatch: + workflow_call: inputs: base-ref: - description: Previous release tag or Git ref required: true + type: string previous-version: - description: Previously published package version (X.Y.Z) - required: true + required: false + type: string package-version: - description: Package version to create (X.Y.Z) required: true + type: string semver-impact: - description: Declared release impact required: true - type: choice - options: [patch, minor, major] + type: string + create-tag: + required: true + type: boolean permissions: - contents: read + contents: write packages: write jobs: - contract-gate: - name: validate prompt contract - uses: ./.github/workflows/validate-prompts.yml - pack: name: pack prompts - needs: contract-gate runs-on: ubuntu-latest environment: production timeout-minutes: 15 @@ -56,6 +52,7 @@ jobs: run: dotnet restore tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj - name: Validate release version and impact + if: inputs['previous-version'] != '' run: >- dotnet run --project tools/DevComputaria.Prompts.Cli --configuration Release --no-restore -- @@ -64,6 +61,14 @@ jobs: --previous-version "${{ inputs['previous-version'] }}" --next-version "${{ inputs['package-version'] }}" + - name: Validate initial release impact + if: inputs['previous-version'] == '' + run: >- + dotnet run --project tools/DevComputaria.Prompts.Cli + --configuration Release --no-restore -- + release-check --base "${{ inputs['base-ref'] }}" + --impact "${{ inputs['semver-impact'] }}" + - name: Pack validated catalog run: >- dotnet pack src/DevComputaria.Prompts/DevComputaria.Prompts.csproj @@ -90,3 +95,13 @@ jobs: --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --api-key "$NUGET_AUTH_TOKEN" --skip-duplicate + + - name: Tag published commit + if: inputs['create-tag'] + env: + PACKAGE_VERSION: ${{ inputs['package-version'] }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag --annotate "v$PACKAGE_VERSION" --message "Release v$PACKAGE_VERSION" + git push origin "v$PACKAGE_VERSION" diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 0000000..e30fdeb --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,164 @@ +name: publish-packages + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: publish-packages + cancel-in-progress: false + +permissions: + contents: write + packages: write + +jobs: + guard: + name: require main branch + runs-on: ubuntu-latest + steps: + - name: Reject non-main releases + env: + RELEASE_REF: ${{ github.ref }} + run: | + if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then + echo "Packages can only be published from main; received '$RELEASE_REF'." >&2 + exit 1 + fi + + contract-gate: + name: validate tests and prompt contract + needs: guard + uses: ./.github/workflows/validate-prompts.yml + + release-plan: + name: calculate release version + needs: contract-gate + runs-on: ubuntu-latest + outputs: + base-ref: ${{ steps.plan.outputs.base-ref }} + previous-version: ${{ steps.plan.outputs.previous-version }} + package-version: ${{ steps.plan.outputs.package-version }} + semver-impact: ${{ steps.plan.outputs.semver-impact }} + create-tag: ${{ steps.plan.outputs.create-tag }} + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: true + steps: + - name: Checkout repository history + 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 + tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj + + - name: Restore release tooling + run: dotnet restore tools/DevComputaria.Prompts.Cli/DevComputaria.Prompts.Cli.csproj + + - name: Calculate version from repository history + id: plan + shell: bash + run: | + set -euo pipefail + + current_tag="$(git tag --points-at HEAD --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1)" + latest_tag="$(git tag --merged HEAD^ --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1)" + + if [[ -n "$latest_tag" ]]; then + base_ref="$latest_tag" + previous_version="${latest_tag#v}" + else + base_ref="$(git rev-list --max-parents=0 HEAD | tail -n 1)" + previous_version="" + fi + + governance_output="$(dotnet run --project tools/DevComputaria.Prompts.Cli \ + --configuration Release --no-restore -- \ + release-check --base "$base_ref" --impact major)" + printf '%s\n' "$governance_output" + + impact="$(printf '%s\n' "$governance_output" \ + | sed -n 's/^Required SemVer impact: //p' \ + | tr '[:upper:]' '[:lower:]')" + if [[ ! "$impact" =~ ^(patch|minor|major)$ ]]; then + echo "Could not calculate a SemVer impact from release-check." >&2 + exit 1 + fi + + if [[ -n "$current_tag" ]]; then + package_version="${current_tag#v}" + create_tag=false + elif [[ -z "$previous_version" ]]; then + package_version="$(jq -r '.version' version.json)" + if [[ "$package_version" =~ ^[0-9]+\.[0-9]+$ ]]; then + package_version="${package_version}.0" + fi + create_tag=true + else + IFS=. read -r major minor patch <<< "$previous_version" + case "$impact" in + patch) package_version="$major.$minor.$((patch + 1))" ;; + minor) package_version="$major.$((minor + 1)).0" ;; + major) package_version="$((major + 1)).0.0" ;; + esac + create_tag=true + fi + + if [[ ! "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Calculated package version '$package_version' is not X.Y.Z." >&2 + exit 1 + fi + + if [[ -n "$previous_version" ]]; then + dotnet run --project tools/DevComputaria.Prompts.Cli \ + --configuration Release --no-restore -- \ + release-check --base "$base_ref" --impact "$impact" \ + --previous-version "$previous_version" \ + --next-version "$package_version" + else + dotnet run --project tools/DevComputaria.Prompts.Cli \ + --configuration Release --no-restore -- \ + release-check --base "$base_ref" --impact "$impact" + fi + + { + echo "base-ref=$base_ref" + echo "previous-version=$previous_version" + echo "package-version=$package_version" + echo "semver-impact=$impact" + echo "create-tag=$create_tag" + } >> "$GITHUB_OUTPUT" + + echo "Release plan: ${current_tag:-new tag v$package_version}, base $base_ref, impact $impact." + + publish-promptkit: + name: publish PromptKit + needs: release-plan + uses: ./.github/workflows/pack-promptkit.yml + with: + base-ref: ${{ needs.release-plan.outputs.base-ref }} + previous-version: ${{ needs.release-plan.outputs.previous-version }} + package-version: ${{ needs.release-plan.outputs.package-version }} + semver-impact: ${{ needs.release-plan.outputs.semver-impact }} + + publish-prompts: + name: publish Prompts and finalize release + needs: + - release-plan + - publish-promptkit + uses: ./.github/workflows/pack-prompts.yml + with: + base-ref: ${{ needs.release-plan.outputs.base-ref }} + previous-version: ${{ needs.release-plan.outputs.previous-version }} + package-version: ${{ needs.release-plan.outputs.package-version }} + semver-impact: ${{ needs.release-plan.outputs.semver-impact }} + create-tag: ${{ needs.release-plan.outputs.create-tag == 'true' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f744868..8e0eae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,11 @@ The format is inspired by Keep a Changelog and adapted to the execution model of - 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. +- Pull-request release-governance workflow and automatic tag-based package version planning. - 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. +- Input-free `publish-packages` workflow that runs automatically on `main`, derives SemVer from the latest reviewed commit and release tag, and serializes publications. ## [2026-08-30] diff --git a/README.md b/README.md index a34651f..bab06df 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ DevComputaria.Prompting/ └── workflows/ ├── validate-prompts.yml ├── release-governance.yml + ├── publish-packages.yml ├── pack-promptkit.yml └── pack-prompts.yml ``` diff --git a/docs/SECURITY-AND-RELEASE-GOVERNANCE.md b/docs/SECURITY-AND-RELEASE-GOVERNANCE.md index 9da3470..381fc9f 100644 --- a/docs/SECURITY-AND-RELEASE-GOVERNANCE.md +++ b/docs/SECURITY-AND-RELEASE-GOVERNANCE.md @@ -10,7 +10,7 @@ It translates ADR-001/ADR-002 into review, access, logging, and release controls | 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 | +| Release manager | Verify the release checklist and approve promotion of validated artifacts | Run the governed publish workflow through the protected release environment | Cannot supply or override a calculated version, 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, @@ -28,8 +28,8 @@ Configure `main` with all of the following controls: - 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; +- restrict the production package environment to release managers and tag creation + matching `v*` to the governed publish workflow identity; - use least-privilege workflow permissions and environment-scoped package credentials. The role names are stable policy identifiers. Team names and membership are managed at @@ -70,9 +70,12 @@ request with its base ref and computes the minimum impact: | 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. +A higher declared impact is allowed during pull-request review. During package creation, +`publish-packages.yml` calculates the minimum impact since the latest reachable `vX.Y.Z` +tag, derives the exact next version, and asks the command to validate that transition. The +first release uses `version.json`; subsequent releases cannot accept manual version inputs. +The workflow starts for every reviewed commit pushed to `main`; manual dispatch is retained +only for idempotent recovery of an interrupted run. Release concurrency is serialized. Local example: @@ -104,8 +107,10 @@ dotnet run --project tools/DevComputaria.Prompts.Cli -- \ ### 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. +- [ ] The release runs from the latest reviewed commit on `main`. +- [ ] The automatic `push` run, or a manual recovery of that same commit, is used. +- [ ] The workflow identifies the previous protected tag and calculates the exact + next `X.Y.Z` version from the required 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 @@ -114,7 +119,8 @@ dotnet run --project tools/DevComputaria.Prompts.Cli -- \ - [ ] 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. +- [ ] The workflow creates the protected `vX.Y.Z` tag only after both registry + publications succeed. ### After release @@ -130,6 +136,6 @@ dotnet run --project tools/DevComputaria.Prompts.Cli -- \ - 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`. +- Automatic version planning and release orchestration: `publish-packages.yml`. +- GitHub Packages publication: `pack-promptkit.yml` and `pack-prompts.yml`.