From 6e07e8bd27f04d107fc4820ea67888dfbc5e770b Mon Sep 17 00:00:00 2001 From: John Smith Date: Thu, 16 Jul 2026 10:51:19 +0100 Subject: [PATCH] ci: harden release workflow --- .github/workflows/ci.yml | 95 ++++++++++++++++++++++++++++----- AGENTS.md | 54 +++++++++++++++++++ CLAUDE.md | 84 +++++++++++++---------------- src/solrevdev.seedfolder.csproj | 2 +- 4 files changed, 174 insertions(+), 61 deletions(-) create mode 100644 AGENTS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34953b2..90fca36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,11 @@ name: CI permissions: - contents: write + contents: read + +concurrency: + group: ci-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} on: push: @@ -26,6 +30,8 @@ jobs: build: if: github.event_name == 'pull_request' || (github.event_name == 'push' && contains(toJson(github.event.commits), '***NO_CI***') == false && contains(toJson(github.event.commits), '[ci skip]') == false && contains(toJson(github.event.commits), '[skip ci]') == false) runs-on: ubuntu-latest + outputs: + publication_succeeded: ${{ steps.publish.outputs.succeeded }} env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 @@ -37,6 +43,8 @@ jobs: steps: - name: checkout code uses: actions/checkout@v5 + with: + persist-credentials: false - name: setup .net core sdk uses: actions/setup-dotnet@v5 @@ -55,28 +63,87 @@ jobs: - name: dotnet pack run: dotnet pack solrevdev.seedfolder.sln -c Release --no-build --include-source --include-symbols - - name: Publish NuGet - if: github.event_name == 'push' && github.ref == 'refs/heads/master' - env: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + - name: verify versioned package + id: package + shell: bash run: | version=$(sed -n 's:.*\(.*\).*:\1:p' src/solrevdev.seedfolder.csproj) package="src/nupkg/solrevdev.seedfolder.${version}.nupkg" - tag="v${version}" - if [[ -z "$version" || ! -f "$package" ]]; then - echo "Unable to find the package version or package file." >&2 + if [[ -z "$version" ]]; then + echo "Unable to find the package version." >&2 + exit 1 + fi + + if [[ ! -f "$package" ]]; then + echo "Expected package does not exist: ${package}" >&2 exit 1 fi - dotnet nuget push "$package" \ + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "package=${package}" >> "$GITHUB_OUTPUT" + + - name: publish NuGet + id: publish + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + PACKAGE: ${{ steps.package.outputs.package }} + run: | + dotnet nuget push "$PACKAGE" \ --api-key "$NUGET_API_KEY" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate - if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then - echo "Tag ${tag} already exists; no tag update needed." - else - git tag "$tag" - git push origin "$tag" + echo "succeeded=true" >> "$GITHUB_OUTPUT" + + tag-release: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.build.result == 'success' && needs.build.outputs.publication_succeeded == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: checkout published commit + uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + + - name: verify or create release tag + shell: bash + run: | + expected_commit="$GITHUB_SHA" + version=$(sed -n 's:.*\(.*\).*:\1:p' src/solrevdev.seedfolder.csproj) + tag="v${version}" + + if [[ -z "$version" ]]; then + echo "Unable to find the package version." >&2 + exit 1 fi + + if [[ "$(git rev-parse HEAD)" != "$expected_commit" ]]; then + echo "Checked-out commit does not match the intended release commit." >&2 + exit 1 + fi + + if git ls-remote --exit-code --refs origin "refs/tags/${tag}" >/dev/null 2>&1; then + git fetch --no-tags origin "refs/tags/${tag}:refs/tags/${tag}" + tag_commit=$(git rev-list -n 1 "refs/tags/${tag}") + tag_version=$(git show "refs/tags/${tag}:src/solrevdev.seedfolder.csproj" | sed -n 's:.*\(.*\).*:\1:p') + + if [[ "$tag_commit" != "$expected_commit" ]]; then + echo "Tag ${tag} resolves to ${tag_commit}, expected ${expected_commit}." >&2 + exit 1 + fi + + if [[ "$tag_version" != "$version" ]]; then + echo "Tag ${tag} contains version ${tag_version}, expected ${version}." >&2 + exit 1 + fi + + echo "Tag ${tag} already identifies the published commit and version; safe retry." + exit 0 + fi + + git tag "$tag" "$expected_commit" + git push origin "refs/tags/${tag}:refs/tags/${tag}" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4cf087d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# Repository Agent Guide + +This file documents the project-specific guidance for coding agents working in this repository. + +## Project overview + +`seedfolder` is a .NET Global Tool that creates project directories from embedded templates. The application is a single-file console program in `src/Program.cs` and targets `net8.0`, `net9.0`, and `net10.0`. + +## Required validation + +Run the same Release-mode sequence used by CI: + +```bash +dotnet build solrevdev.seedfolder.sln --configuration Release +./tests/integration-test.sh +dotnet pack solrevdev.seedfolder.sln -c Release --no-build --include-source --include-symbols +``` + +Packages are written to `src/nupkg/`. For interactive local install/uninstall testing, see `build/test-local.sh` and review its global-tool side effects before running it. + +Run the tool directly with: + +```bash +dotnet run --project src/solrevdev.seedfolder.csproj +dotnet run --project src/solrevdev.seedfolder.csproj -- myfolder +dotnet run --project src/solrevdev.seedfolder.csproj -- --dry-run -t node myfolder +``` + +## Architecture + +- `src/Program.cs` contains CLI parsing, interactive prompts, validation, rendering, and template extraction. +- `McMaster.Extensions.CommandLineUtils` supplies prompt helpers; option parsing is implemented in `Main()` and accepts options before or after the single folder-name argument. +- `Figgle` and `Figgle.Fonts` render the ASCII header; colored output uses `Console.ForegroundColor` through the local `WriteLine()` helper. +- `src/Data/` files are embedded by `src/solrevdev.seedfolder.csproj` and extracted with `WriteFileAsync()`. +- `TemplateFile` and `ProjectType` define the six templates: markdown, dotnet, node, python, ruby, and universal. +- Folder names pass through `RemoveSpaces()` and `SafeNameForFileSystem()` before use. + +## Release model + +- `` in `src/solrevdev.seedfolder.csproj` is the release source of truth and is bumped manually. +- Pull requests and `release/*` pushes build, test, pack, and verify the exact versioned package; only a push to `master` publishes to NuGet. +- A publish-capable change must use a version that is not already associated with another commit. After NuGet publication succeeds, CI creates the lightweight `v` tag for that exact master commit. +- Release retries are accepted only when the existing tag resolves to the same commit and its csproj contains the same version. +- Keep `actions/checkout@v5` and `actions/setup-dotnet@v5` unless a deliberate, validated action-major upgrade is required; both use the Node 24 action runtime. +- NuGet publication uses native `dotnet nuget push --skip-duplicate` and requires the `NUGET_API_KEY` Actions secret. +- Push commits containing `***NO_CI***`, `[ci skip]`, or `[skip ci]` skip the build job. Pull-request validation is not bypassed by these markers. + +## Commit messages + +Use Conventional Commits with an imperative subject, for example `ci: harden release workflow`. Add a scope when useful and mark breaking changes with `!` and/or a `BREAKING CHANGE:` footer. + +## Global versus repository guidance + +Keep machine-specific operational rules—Python environment management, browser tooling, SVG rasterization, safe deletion utilities, and Homebrew/.NET SDK repair—in global agent settings. They are useful defaults but are not requirements of this .NET project. diff --git a/CLAUDE.md b/CLAUDE.md index 08a8e29..4cf087d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,62 +1,54 @@ -# CLAUDE.md +# Repository Agent Guide -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file documents the project-specific guidance for coding agents working in this repository. -## Project Overview +## Project overview -This is a .NET Core Global Tool called `seedfolder` that creates directories and populates them with standard dotfiles. The tool is designed to quickly scaffold project folders with common configuration files. +`seedfolder` is a .NET Global Tool that creates project directories from embedded templates. The application is a single-file console program in `src/Program.cs` and targets `net8.0`, `net9.0`, and `net10.0`. -## Development Commands +## Required validation + +Run the same Release-mode sequence used by CI: -### Build and Test ```bash -# Build the solution dotnet build solrevdev.seedfolder.sln --configuration Release +./tests/integration-test.sh +dotnet pack solrevdev.seedfolder.sln -c Release --no-build --include-source --include-symbols +``` -# Pack for local installation -dotnet pack - -# Install locally for testing -dotnet tool install --global --add-source ./nupkg solrevdev.seedfolder +Packages are written to `src/nupkg/`. For interactive local install/uninstall testing, see `build/test-local.sh` and review its global-tool side effects before running it. -# Uninstall after testing -dotnet tool uninstall -g solrevdev.seedfolder -``` +Run the tool directly with: -### Run the Tool During Development ```bash -# Run with interactive prompts dotnet run --project src/solrevdev.seedfolder.csproj - -# Run with folder name argument -dotnet run --project src/solrevdev.seedfolder.csproj myfolder +dotnet run --project src/solrevdev.seedfolder.csproj -- myfolder +dotnet run --project src/solrevdev.seedfolder.csproj -- --dry-run -t node myfolder ``` ## Architecture -### Single File Structure -The entire application is contained in `src/Program.cs` - a single-file console application using: -- **McMaster.Extensions.CommandLineUtils** for command-line parsing and prompts -- **Figgle** / **Figgle.Fonts** for ASCII art header generation -- **Console.ForegroundColor** for colored console output - -### Embedded Resources -Template files are stored as embedded resources in `src/Data/` and copied to new folders: -- `dockerignore` → `.dockerignore` -- `editorconfig` → `.editorconfig` -- `gitattributes` → `.gitattributes` -- `gitignore` → `.gitignore` -- `prettierignore` → `.prettierignore` -- `prettierrc` → `.prettierrc` -- `omnisharp.json` → `omnisharp.json` - -### Key Methods -- `WriteFileAsync()` - Extracts embedded resources and writes them to the target directory -- `RemoveSpaces()` - Sanitizes folder names by replacing spaces with dashes -- `SafeNameForFileSystem()` - Removes invalid filesystem characters from folder names - -## Multi-Target Framework Support -The project targets .NET 8.0 (LTS), 9.0 (STS), and 10.0 (LTS) to support current and modern .NET versions. .NET 8 provides long-term support until November 2026, .NET 9 offers the latest features with 18-month support, and .NET 10 is the latest LTS release providing long-term support until November 2028. - -## CI/CD -GitHub Actions workflow builds, packs, and publishes to NuGet on pushes to master branch. Version is controlled by the `` property in the .csproj file. \ No newline at end of file +- `src/Program.cs` contains CLI parsing, interactive prompts, validation, rendering, and template extraction. +- `McMaster.Extensions.CommandLineUtils` supplies prompt helpers; option parsing is implemented in `Main()` and accepts options before or after the single folder-name argument. +- `Figgle` and `Figgle.Fonts` render the ASCII header; colored output uses `Console.ForegroundColor` through the local `WriteLine()` helper. +- `src/Data/` files are embedded by `src/solrevdev.seedfolder.csproj` and extracted with `WriteFileAsync()`. +- `TemplateFile` and `ProjectType` define the six templates: markdown, dotnet, node, python, ruby, and universal. +- Folder names pass through `RemoveSpaces()` and `SafeNameForFileSystem()` before use. + +## Release model + +- `` in `src/solrevdev.seedfolder.csproj` is the release source of truth and is bumped manually. +- Pull requests and `release/*` pushes build, test, pack, and verify the exact versioned package; only a push to `master` publishes to NuGet. +- A publish-capable change must use a version that is not already associated with another commit. After NuGet publication succeeds, CI creates the lightweight `v` tag for that exact master commit. +- Release retries are accepted only when the existing tag resolves to the same commit and its csproj contains the same version. +- Keep `actions/checkout@v5` and `actions/setup-dotnet@v5` unless a deliberate, validated action-major upgrade is required; both use the Node 24 action runtime. +- NuGet publication uses native `dotnet nuget push --skip-duplicate` and requires the `NUGET_API_KEY` Actions secret. +- Push commits containing `***NO_CI***`, `[ci skip]`, or `[skip ci]` skip the build job. Pull-request validation is not bypassed by these markers. + +## Commit messages + +Use Conventional Commits with an imperative subject, for example `ci: harden release workflow`. Add a scope when useful and mark breaking changes with `!` and/or a `BREAKING CHANGE:` footer. + +## Global versus repository guidance + +Keep machine-specific operational rules—Python environment management, browser tooling, SVG rasterization, safe deletion utilities, and Homebrew/.NET SDK repair—in global agent settings. They are useful defaults but are not requirements of this .NET project. diff --git a/src/solrevdev.seedfolder.csproj b/src/solrevdev.seedfolder.csproj index fa1f950..19714e2 100644 --- a/src/solrevdev.seedfolder.csproj +++ b/src/solrevdev.seedfolder.csproj @@ -9,7 +9,7 @@ seedfolder ./nupkg true - 1.4.1 + 1.4.2 solrevdev.seedfolder .NET Core Global Tool that creates a folder and copies dotfiles into it therefore seeding a folder. .NET Core Global Tool that creates a folder and copies dotfiles into it therefore seeding a folder.