From 259bed05fb1140e4250c024b6af759ea576f3b48 Mon Sep 17 00:00:00 2001 From: saan800 Date: Sat, 16 May 2026 00:25:12 +0800 Subject: [PATCH 1/2] chore: refactor for top level workflows --- .claude/agent-memory/MEMORY.md | 9 ++ .github/dependabot.yml | 2 - .../workflows/_check-release-eligibility.yml | 5 +- .../_dependabot-auto-approve-and-merge.yml | 12 +- .github/workflows/_dependency-review.yml | 31 +++++ .../_dotnet-build-test-pack-publish-nuget.yml | 20 ++- .github/workflows/_dotnet-publish-nuget.yml | 8 +- .github/workflows/_github-tag-and-release.yml | 9 +- .github/workflows/_pr-labeler.yml | 13 +- .github/workflows/_pr-lint.yml | 8 +- .../workflows/dependabot-review-and-merge.yml | 3 +- .github/workflows/dotnet-package-pr.yml | 119 ++++++++++++++++++ .github/workflows/dotnet-package-release.yml | 87 +++++++++++++ .github/workflows/example-nuget-packages.yml | 5 +- .github/workflows/pr.yml | 6 +- CLAUDE.md | 4 + 16 files changed, 293 insertions(+), 48 deletions(-) create mode 100644 .claude/agent-memory/MEMORY.md create mode 100644 .github/workflows/_dependency-review.yml create mode 100644 .github/workflows/dotnet-package-pr.yml create mode 100644 .github/workflows/dotnet-package-release.yml diff --git a/.claude/agent-memory/MEMORY.md b/.claude/agent-memory/MEMORY.md new file mode 100644 index 0000000..6cfaf89 --- /dev/null +++ b/.claude/agent-memory/MEMORY.md @@ -0,0 +1,9 @@ +# Agent Memory + +## Feedback + +### NuGet .snupkg push to NuGet.org +When reviewing `dotnet nuget push` steps targeting nuget.org, do NOT flag the absence of a separate `*.snupkg` push step. NuGet.org automatically detects and ingests the `.snupkg` symbol package when the corresponding `.nupkg` is pushed. + +### Dependabot NuGet PRs and the no-release label +NuGet Dependabot PRs in `dependabot.yml` intentionally omit the `no-release` label. NuGet dependency bumps are deliberately release-worthy so consumers get updated packages. Do NOT flag this as missing. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5a5257b..ddf9453 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -55,7 +55,6 @@ updates: patterns: - "AwesomeAssertions*" - "coverlet*" - - "FakeItEasy*" - "GitHubActionsTestLogger" - "xunit*" update-types: ["minor", "patch"] @@ -83,7 +82,6 @@ updates: patterns: - "AwesomeAssertions*" - "coverlet*" - - "FakeItEasy*" - "GitHubActionsTestLogger" - "xunit*" update-types: ["minor", "patch"] diff --git a/.github/workflows/_check-release-eligibility.yml b/.github/workflows/_check-release-eligibility.yml index 728b7f3..796182b 100644 --- a/.github/workflows/_check-release-eligibility.yml +++ b/.github/workflows/_check-release-eligibility.yml @@ -13,7 +13,7 @@ on: required: false default: false secrets: - GITHUB_ACCESS_TOKEN: + GITHUB_TOKEN: description: "GitHub token" required: true outputs: @@ -34,7 +34,7 @@ jobs: - name: Determine release eligibility id: set-output env: - GITHUB_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} SHA: ${{ github.sha }} IS_RELEASE_BRANCH: ${{ inputs.is-release-branch }} @@ -70,3 +70,4 @@ jobs: fi echo "should-release=$should_release" >> $GITHUB_OUTPUT + echo "should-release=$should_release" diff --git a/.github/workflows/_dependabot-auto-approve-and-merge.yml b/.github/workflows/_dependabot-auto-approve-and-merge.yml index 61d135b..a1c9b3a 100644 --- a/.github/workflows/_dependabot-auto-approve-and-merge.yml +++ b/.github/workflows/_dependabot-auto-approve-and-merge.yml @@ -12,8 +12,8 @@ on: required: true type: string secrets: - GITHUB_ACCESS_TOKEN: - description: "(ie: GITHUB_TOKEN) GitHub token" + GITHUB_TOKEN: + description: "GitHub token" required: true jobs: @@ -28,6 +28,7 @@ jobs: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: + disable-sudo: true egress-policy: ${{ inputs.harden-runner-policy }} - name: Checkout @@ -40,7 +41,7 @@ jobs: - name: Check for merge conflicts uses: sv-tools/block-merge-conflicts@911859e7a913f086e9f89db5117e0942690bd64e # v2.0.0 with: - token: ${{ secrets.GITHUB_ACCESS_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} - name: Approve a PR if not already approved if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }} @@ -52,7 +53,7 @@ jobs: fi env: PR_URL: ${{ inputs.pr-url}} - GITHUB_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} merge: runs-on: ubuntu-latest @@ -64,6 +65,7 @@ jobs: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: + disable-sudo: true egress-policy: ${{ inputs.harden-runner-policy }} - name: Checkout @@ -74,4 +76,4 @@ jobs: run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ inputs.pr-url}} - GITHUB_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/_dependency-review.yml b/.github/workflows/_dependency-review.yml new file mode 100644 index 0000000..15f6339 --- /dev/null +++ b/.github/workflows/_dependency-review.yml @@ -0,0 +1,31 @@ +name: dependency-review + +on: + workflow_call: + inputs: + harden-runner-policy: + description: "The egress policy for the Harden Runner step. Defaults to 'block'" + required: false + type: string + default: "block" + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@6c3c2f2c1c457b00c10c4848d6f5491db3b629df # v2.18.0 + with: + disable-sudo: true + egress-policy: ${{ inputs.harden-runner-policy }} + allowed-endpoints: > + api.deps.dev:443 + api.github.com:443 + api.securityscorecards.dev:443 + github.com:443 + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run dependency review + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 diff --git a/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml b/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml index c52b0fc..a0c89f2 100644 --- a/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml +++ b/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml @@ -51,8 +51,8 @@ on: required: false default: false secrets: - GITHUB_ACCESS_TOKEN: - description: "(ie: GITHUB_TOKEN) GitHub token" + GITHUB_TOKEN: + description: "GitHub token" required: true NUGET_API_KEY: description: "API key for nuget.org. Required if upload-to-nuget is true" @@ -62,7 +62,8 @@ on: required: false permissions: - contents: read + contents: write + packages: write pull-requests: read jobs: @@ -80,8 +81,7 @@ jobs: with: is-release-branch: ${{ inputs.is-release-branch }} force-release: ${{ inputs.force-release }} - secrets: - GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + secrets: inherit build-and-test: uses: ./.github/workflows/_dotnet-build-and-test.yml @@ -92,8 +92,7 @@ jobs: dotnet-version-matrix: ${{ inputs.dotnet-version-matrix }} dotnet-version: ${{ inputs.dotnet-version }} codecov-slug: ${{ inputs.codecov-slug }} - secrets: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + secrets: inherit pack: needs: @@ -126,9 +125,7 @@ jobs: num-github-prerelease-packages-to-keep: ${{ inputs.num-github-prerelease-packages-to-keep }} upload-to-github: ${{ inputs.is-release-branch == false || needs.check-release-eligibility.outputs.should-release == 'false' }} upload-to-nuget: ${{ inputs.is-release-branch == true && needs.check-release-eligibility.outputs.should-release == 'true' }} - secrets: - GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + secrets: inherit release: needs: @@ -142,5 +139,4 @@ jobs: with: harden-runner-policy: ${{ inputs.harden-runner-policy }} version: ${{ needs.get-version.outputs.version }} - secrets: - GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + secrets: inherit diff --git a/.github/workflows/_dotnet-publish-nuget.yml b/.github/workflows/_dotnet-publish-nuget.yml index 6d20e99..87e9f6b 100644 --- a/.github/workflows/_dotnet-publish-nuget.yml +++ b/.github/workflows/_dotnet-publish-nuget.yml @@ -42,8 +42,8 @@ on: required: false default: false secrets: - GITHUB_ACCESS_TOKEN: - description: "(ie: GITHUB_TOKEN) GitHub token to upload package to GitHub Package Registry. Required if upload-to-github is true" + GITHUB_TOKEN: + description: "GitHub token to upload package to GitHub Package Registry. Required if upload-to-github is true" required: false NUGET_API_KEY: description: "API key for nuget.org. Required if upload-to-nuget is true" @@ -95,7 +95,7 @@ jobs: dotnet nuget push build-packages/*.nupkg --skip-duplicate --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json - --api-key ${{ secrets.GITHUB_ACCESS_TOKEN }} + --api-key ${{ secrets.GITHUB_TOKEN }} - name: Publish symbol package(s) to GitHub Package Registry if: ${{ inputs.upload-to-github }} @@ -104,7 +104,7 @@ jobs: dotnet nuget push build-packages/*.snupkg --skip-duplicate --source https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json - --api-key ${{ secrets.GITHUB_ACCESS_TOKEN }} + --api-key ${{ secrets.GITHUB_TOKEN }} - name: Publish nuget and symbols package(s) to NuGet.org if: ${{ inputs.upload-to-nuget }} diff --git a/.github/workflows/_github-tag-and-release.yml b/.github/workflows/_github-tag-and-release.yml index fbc6b99..e9d1431 100644 --- a/.github/workflows/_github-tag-and-release.yml +++ b/.github/workflows/_github-tag-and-release.yml @@ -13,8 +13,8 @@ on: required: true type: string secrets: - GITHUB_ACCESS_TOKEN: - description: "(ie: GITHUB_TOKEN) GitHub token to upload tag and release with" + GITHUB_TOKEN: + description: "GitHub token to upload tag and release with" required: true permissions: @@ -30,6 +30,7 @@ jobs: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 with: + disable-sudo: true egress-policy: ${{ inputs.harden-runner-policy }} - name: Checkout @@ -42,7 +43,7 @@ jobs: - name: Tag env: - GITHUB_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ inputs.version }}" git tag -a "v$VERSION" -m "Release version $VERSION" @@ -51,7 +52,7 @@ jobs: - name: Github Release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: name: ${{ inputs.version }} tag_name: v${{ inputs.version }} diff --git a/.github/workflows/_pr-labeler.yml b/.github/workflows/_pr-labeler.yml index 3c87ce6..ca54124 100644 --- a/.github/workflows/_pr-labeler.yml +++ b/.github/workflows/_pr-labeler.yml @@ -9,8 +9,8 @@ on: type: string default: "block" secrets: - GITHUB_ACCESS_TOKEN: - description: "(ie: GITHUB_TOKEN) GitHub token" + GITHUB_TOKEN: + description: "GitHub token" required: true permissions: @@ -37,7 +37,7 @@ jobs: - name: Validate PR title uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: # Configure which types are allowed (newline-delimited). # Default: https://github.com/commitizen/conventional-commit-types @@ -98,7 +98,7 @@ jobs: - name: Ensure Labels Exist uses: actions/github-script@v9 with: - github-token: ${{ secrets.GITHUB_ACCESS_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | const labelConfig = { breaking: { color: "b60205", description: "Introduces a breaking change" }, @@ -111,7 +111,8 @@ jobs: "help wanted": { color: "0E8A16", description: "Extra attention is needed" }, invalid: { color: "e4e669", description: "This doesn't seem right" }, misc: { color: "7057ff", description: "Non-functional changes such as performance, tests and refactoring" }, - "no-release": { color: "999999", description: "Changes don't require a release and wont be included in the release notes" }, + "no-release": { color: "999999", description: "Changes don't require a release" }, + "no-release-notes": { color: "999999", description: "Changes wont be included in the release notes" }, question: { color: "d876e3", description: "Further information is requested" }, wontfix: { color: "ffffff", description: "This will not be worked on" }, }; @@ -143,7 +144,7 @@ jobs: - name: Label PR uses: actions/github-script@v9 with: - github-token: ${{ secrets.GITHUB_ACCESS_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} script: | const pr = context.payload.pull_request; const title = pr.title.toLowerCase(); diff --git a/.github/workflows/_pr-lint.yml b/.github/workflows/_pr-lint.yml index 9a8dbcf..8211632 100644 --- a/.github/workflows/_pr-lint.yml +++ b/.github/workflows/_pr-lint.yml @@ -17,8 +17,8 @@ on: required: false type: string secrets: - GITHUB_ACCESS_TOKEN: - description: "(ie: GITHUB_TOKEN) GitHub token" + GITHUB_TOKEN: + description: "GitHub token" required: true permissions: @@ -50,7 +50,7 @@ jobs: uses: super-linter/super-linter@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0 env: # To report GitHub Actions status checks - GITHUB_TOKEN: ${{ secrets.GITHUB_ACCESS_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FILTER_REGEX_EXCLUDE: (./\.editorconfig|\.idea/*|\.vscode/*) IGNORE_GENERATED_FILES: true # false -> only checks changed files @@ -65,7 +65,7 @@ jobs: spell-check: runs-on: ubuntu-latest - if: ${{ inputs.cspell-config != null }} + if: ${{ inputs.cspell-config != '' }} steps: - name: Harden Runner uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 diff --git a/.github/workflows/dependabot-review-and-merge.yml b/.github/workflows/dependabot-review-and-merge.yml index db5fd53..aad3c1d 100644 --- a/.github/workflows/dependabot-review-and-merge.yml +++ b/.github/workflows/dependabot-review-and-merge.yml @@ -18,5 +18,4 @@ jobs: uses: ./.github/workflows/_dependabot-auto-approve-and-merge.yml with: pr-url: ${{ github.event.pull_request.html_url }} - secrets: - GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + secrets: inherit diff --git a/.github/workflows/dotnet-package-pr.yml b/.github/workflows/dotnet-package-pr.yml new file mode 100644 index 0000000..3553d6a --- /dev/null +++ b/.github/workflows/dotnet-package-pr.yml @@ -0,0 +1,119 @@ +name: dotnet-build-test-pack-publish-nuget + +on: + workflow_call: + inputs: + harden-runner-policy: + description: "The egress policy for the Harden Runner step. Defaults to 'block'" + required: false + type: string + default: "block" + working-directory: + description: "The working directory for the job. Defaults to '.' which is the root of the repository. Do not include trailing '/'" + required: false + type: string + default: "." + os: + description: The operating system to run non-matrix jobs on. e.g. "ubuntu-latest" or "windows-latest". + required: false + type: string + default: ubuntu-latest + dotnet-version-matrix: + description: 'JSON array of dotnet-version/framework pairs. Must be on one line. eg ''[{ "dotnet-version": "9.0.x", "framework": "net9.0" }, { "dotnet-version": "10.0.x", "framework": "net10.0" }]''' + required: false + type: string + dotnet-version: + description: The dotnet version to run the non-matrix jobs with. e.g. "10.0.x". + required: false + type: string + default: 10.0.x + run-lint: + required: false + type: boolean + default: false + cspell-config: + description: "Path to the cspell configuration file. Required to run spell-checking. eg '.cspell.json'" + required: false + type: string + num-github-prerelease-packages-to-keep: + description: "We cleanup github prerelease nuget packages. Configure how many to keep" + type: number + required: false + default: 50 + codecov-slug: + description: "Slug to upload code coverage results for Codecov. e.g. saan800/saansoft-correlationId" + required: false + type: string + codecov-flag: + description: "Flag to use for Codecov. e.g. unittests" + required: false + type: string + default: unittests + is-release-branch: + description: 'If its a release branch or not. eg release from main check would be "github.ref==''refs/heads/main''" ' + type: boolean + required: true + force-release: + description: "Force a release regardless of PR labels or branch" + type: boolean + required: false + default: false + secrets: + GITHUB_TOKEN: + description: "GitHub token" + required: true + NUGET_API_KEY: + description: "API key for nuget.org. Required if upload-to-nuget is true" + required: false + CODECOV_TOKEN: + description: "Token for CodeCov. Required if codecov-slug is set" + required: false + +permissions: + contents: write + packages: write + pull-requests: read + +jobs: + ci-cd: + permissions: + contents: write + packages: write + pull-requests: read + uses: ./.github/workflows/_dotnet-build-test-pack-publish-nuget.yml + with: + harden-runner-policy: ${{ inputs.harden-runner-policy }} + working-directory: ${{ inputs.working-directory }} + os: ${{ inputs.os }} + dotnet-version-matrix: ${{ inputs.dotnet-version-matrix }} + dotnet-version: ${{ inputs.dotnet-version }} + num-github-prerelease-packages-to-keep: ${{ inputs.num-github-prerelease-packages-to-keep }} + codecov-slug: ${{ inputs.codecov-slug }} + codecov-flag: ${{ codecov-flag }} + is-release-branch: ${{ inputs.is-release-branch }} + force-release: ${{ inputs.force-release }} + secrets: inherit + + pr-labels: + permissions: + contents: read + pull-requests: write + uses: ./.github/workflows/_pr-labeler.yml + with: + harden-runner-policy: ${{ inputs.harden-runner-policy }} + secrets: inherit + + pr-lint: + permissions: + contents: read + uses: ./.github/workflows/_pr-lint.yml + with: + harden-runner-policy: ${{ inputs.harden-runner-policy }} + run-lint: ${{ inputs.run-lint }} + cspell-config: ${{ inputs.cspell-config }} + secrets: inherit + + dependency-review: + uses: ./.github/workflows/_dependency-review.yml + with: + harden-runner-policy: ${{ inputs.harden-runner-policy }} diff --git a/.github/workflows/dotnet-package-release.yml b/.github/workflows/dotnet-package-release.yml new file mode 100644 index 0000000..49110f2 --- /dev/null +++ b/.github/workflows/dotnet-package-release.yml @@ -0,0 +1,87 @@ +name: dotnet-build-test-pack-publish-nuget + +on: + workflow_call: + inputs: + harden-runner-policy: + description: "The egress policy for the Harden Runner step. Defaults to 'block'" + required: false + type: string + default: "block" + working-directory: + description: "The working directory for the job. Defaults to '.' which is the root of the repository. Do not include trailing '/'" + required: false + type: string + default: "." + os: + description: The operating system to run non-matrix jobs on. e.g. "ubuntu-latest" or "windows-latest". + required: false + type: string + default: ubuntu-latest + dotnet-version-matrix: + description: 'JSON array of dotnet-version/framework pairs. Must be on one line. eg ''[{ "dotnet-version": "9.0.x", "framework": "net9.0" }, { "dotnet-version": "10.0.x", "framework": "net10.0" }]''' + required: false + type: string + dotnet-version: + description: The dotnet version to run the non-matrix jobs with. e.g. "10.0.x". + required: false + type: string + default: 10.0.x + num-github-prerelease-packages-to-keep: + description: "We cleanup github prerelease nuget packages. Configure how many to keep" + type: number + required: false + default: 50 + codecov-slug: + description: "Slug to upload code coverage results for Codecov. e.g. saan800/saansoft-correlationId" + required: false + type: string + codecov-flag: + description: "Flag to use for Codecov. e.g. unittests" + required: false + type: string + default: unittests + is-release-branch: + description: 'If its a release branch or not. eg release from main check would be "github.ref==''refs/heads/main''" ' + type: boolean + required: true + force-release: + description: "Force a release regardless of PR labels or branch" + type: boolean + required: false + default: false + secrets: + GITHUB_TOKEN: + description: "GitHub token" + required: true + NUGET_API_KEY: + description: "API key for nuget.org. Required if upload-to-nuget is true" + required: false + CODECOV_TOKEN: + description: "Token for CodeCov. Required if codecov-slug is set" + required: false + +permissions: + contents: write + packages: write + pull-requests: read + +jobs: + ci-cd: + permissions: + contents: write + packages: write + pull-requests: read + uses: ./.github/workflows/_dotnet-build-test-pack-publish-nuget.yml + with: + harden-runner-policy: ${{ inputs.harden-runner-policy }} + working-directory: ${{ inputs.working-directory }} + os: ${{ inputs.os }} + dotnet-version-matrix: ${{ inputs.dotnet-version-matrix }} + dotnet-version: ${{ inputs.dotnet-version }} + num-github-prerelease-packages-to-keep: ${{ inputs.num-github-prerelease-packages-to-keep }} + codecov-slug: ${{ inputs.codecov-slug }} + codecov-flag: ${{ codecov-flag }} + is-release-branch: ${{ inputs.is-release-branch }} + force-release: ${{ inputs.force-release }} + secrets: inherit diff --git a/.github/workflows/example-nuget-packages.yml b/.github/workflows/example-nuget-packages.yml index baef46a..15722e4 100644 --- a/.github/workflows/example-nuget-packages.yml +++ b/.github/workflows/example-nuget-packages.yml @@ -24,12 +24,11 @@ jobs: contents: write packages: write pull-requests: read - uses: ./.github/workflows/_dotnet-build-test-pack-publish-nuget.yml + uses: ./.github/workflows/dotnet-package-pr.yml with: working-directory: "./examples/NugetPackages" os: ubuntu-latest dotnet-version: 10.0.x is-release-branch: false # ${{ github.ref == 'refs/heads/main' }} force-release: ${{ github.event.inputs.force-release || false }} - secrets: - GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + secrets: inherit diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6f080bf..c01bc6b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,8 +14,7 @@ jobs: contents: read pull-requests: write uses: ./.github/workflows/_pr-labeler.yml - secrets: - GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + secrets: inherit pr-lint: permissions: @@ -23,5 +22,4 @@ jobs: uses: ./.github/workflows/_pr-lint.yml with: run-lint: true - secrets: - GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + secrets: inherit diff --git a/CLAUDE.md b/CLAUDE.md index d6cd015..1d1f5c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,3 +75,7 @@ PR titles and commit messages must follow [Conventional Commits](https://www.con - Line endings: LF - Spelling: en-GB (dictionary overrides in `dictionary.dic`) - Max line length: 250 (off for Markdown and YAML) + +## Memory + +Store all persistent memory in `.claude/agent-memory/MEMORY.md` in this repository. Do not use the default system memory path (`~/.claude/projects/*/memory/`). Use a flat structure with `##` headings per topic — no separate files per memory entry. From a7bd0117f17000d65b173310a67e66a86052c44a Mon Sep 17 00:00:00 2001 From: saan800 Date: Sat, 16 May 2026 01:03:09 +0800 Subject: [PATCH 2/2] chore: github workflow fixes --- .claude/agent-memory/MEMORY.md | 6 + .../workflows/_check-release-eligibility.yml | 32 ++- .../_dotnet-build-test-pack-publish-nuget.yml | 1 + .github/workflows/_dotnet-publish-nuget.yml | 2 +- .github/workflows/_pr-labeler.yml | 4 +- README.md | 200 +++++++++++++++++- .../Controllers/ItemsController.cs | 3 +- .../src/MinimalWebApi.Api/Program.cs | 5 +- .../MinimalWebApi.Api/Schema/Items/Item.cs | 2 +- .../Schema/Items/ItemsStore.cs | 11 +- .../Controllers/ItemControllerTests.cs | 7 + .../TestConfig/RemoteApiClientProvider.cs | 12 +- .../TestConfig/TestHttpClientFactory.cs | 1 + examples/NugetPackages/Directory.Build.props | 3 +- 14 files changed, 264 insertions(+), 25 deletions(-) diff --git a/.claude/agent-memory/MEMORY.md b/.claude/agent-memory/MEMORY.md index 6cfaf89..8c06005 100644 --- a/.claude/agent-memory/MEMORY.md +++ b/.claude/agent-memory/MEMORY.md @@ -7,3 +7,9 @@ When reviewing `dotnet nuget push` steps targeting nuget.org, do NOT flag the ab ### Dependabot NuGet PRs and the no-release label NuGet Dependabot PRs in `dependabot.yml` intentionally omit the `no-release` label. NuGet dependency bumps are deliberately release-worthy so consumers get updated packages. Do NOT flag this as missing. + +### ItemTests.ATest is an intentional placeholder +The test `ATest` in `examples/MinimalWebApi/tests/MinimalWebApi.Tests.Api/Schema/ItemTests.cs` is a deliberate minimal placeholder — the example project needs at least one unit test to exercise the CI pipeline. Do NOT flag it as trivial, low-quality, or missing real assertions. + +### example-nuget-packages.yml is-release-branch: false is intentional +The hardcoded `is-release-branch: false` in `.github/workflows/example-nuget-packages.yml` is intentional. This example project must never auto-release to NuGet.org. Do NOT flag the commented-out expression or the hardcoded false as an issue. diff --git a/.github/workflows/_check-release-eligibility.yml b/.github/workflows/_check-release-eligibility.yml index 796182b..65601ca 100644 --- a/.github/workflows/_check-release-eligibility.yml +++ b/.github/workflows/_check-release-eligibility.yml @@ -3,6 +3,11 @@ name: check-release-eligibility on: workflow_call: inputs: + harden-runner-policy: + description: "The egress policy for the Harden Runner step. Defaults to 'block'" + required: false + type: string + default: "block" is-release-branch: description: "If this is a release branch (e.g., main)" type: boolean @@ -31,6 +36,14 @@ jobs: outputs: should-release: ${{ steps.set-output.outputs.should-release }} steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + disable-sudo: true + egress-policy: ${{ inputs.harden-runner-policy }} + allowed-endpoints: > + api.github.com:443 + - name: Determine release eligibility id: set-output env: @@ -59,14 +72,21 @@ jobs: # Extract PR numbers PRS=$(echo "$COMMITS" | grep -oE '#[0-9]+' | sort -u | tr -d '#') - # Check labels of each PR - for pr in $PRS; do - LABELS=$(gh api repos/$REPO/pulls/$pr --jq '.labels[].name') - if ! echo "$LABELS" | grep -q "no-release"; then + if [ -z "$PRS" ]; then + # Commits exist but no PR references found - release by default + if [ -n "$COMMITS" ]; then should_release=true - break fi - done + else + # Check labels of each PR + for pr in $PRS; do + LABELS=$(gh api repos/$REPO/pulls/$pr --jq '.labels[].name') + if ! echo "$LABELS" | grep -q "no-release"; then + should_release=true + break + fi + done + fi fi echo "should-release=$should_release" >> $GITHUB_OUTPUT diff --git a/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml b/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml index a0c89f2..bbdf20d 100644 --- a/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml +++ b/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml @@ -79,6 +79,7 @@ jobs: contents: read pull-requests: read with: + harden-runner-policy: ${{ inputs.harden-runner-policy }} is-release-branch: ${{ inputs.is-release-branch }} force-release: ${{ inputs.force-release }} secrets: inherit diff --git a/.github/workflows/_dotnet-publish-nuget.yml b/.github/workflows/_dotnet-publish-nuget.yml index 87e9f6b..78b652c 100644 --- a/.github/workflows/_dotnet-publish-nuget.yml +++ b/.github/workflows/_dotnet-publish-nuget.yml @@ -168,7 +168,7 @@ jobs: matrix: ${{ fromJson(needs.discover-packages.outputs.matrix) }} steps: - name: Delete old versions for ${{ matrix.package }} - uses: actions/delete-package-versions@v5 + uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5 with: package-name: ${{ matrix.package }} package-type: nuget diff --git a/.github/workflows/_pr-labeler.yml b/.github/workflows/_pr-labeler.yml index ca54124..1e681fa 100644 --- a/.github/workflows/_pr-labeler.yml +++ b/.github/workflows/_pr-labeler.yml @@ -96,7 +96,7 @@ jobs: api.github.com:443 - name: Ensure Labels Exist - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -142,7 +142,7 @@ jobs: } - name: Label PR - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/README.md b/README.md index c9e2fd0..6e6adc5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,199 @@ -# GitHub +# github -Common GitHub files that can be re-used across repos +Reusable GitHub Actions workflow templates for .NET projects, with reference example implementations. -Also has a couple of examples for different scenarios, and how to setup the GitHub Actions workflows for them. +## Reusable Workflow Templates + +All templates live in `.github/workflows/` and are prefixed with `_`. Call them from your own workflows using `workflow_call`. + +### Build & Test + +#### `_dotnet-build-and-test.yml` + +Restores, builds, and runs tests with optional framework matrix and Codecov upload. + +| Input | Required | Default | Description | +|---|---|---|---| +| `working-directory` | | `.` | Root of the .NET solution | +| `dotnet-version` | | `10.0.x` | SDK version (single build) | +| `dotnet-version-matrix` | | | JSON array of `{dotnet-version, framework}` pairs for matrix builds | +| `os` | | `ubuntu-latest` | Runner OS | +| `codecov-slug` | | | Repo slug for Codecov upload (skipped if empty) | +| `codecov-flag` | | `unittests` | Codecov flag | + +| Secret | Required | Description | +|---|---|---| +| `CODECOV_TOKEN` | | Token for Codecov | + +--- + +#### `_dotnet-build-and-pack.yml` + +Packs NuGet `.nupkg` and `.snupkg` with version injection and uploads as a build artifact. + +| Input | Required | Default | Description | +|---|---|---|---| +| `working-directory` | | `.` | Root of the .NET solution | +| `package-version` | Yes | | Semver string to inject (e.g. `1.2.3-beta.1`) | +| `package-artifact-name` | | `packages` | Name for the uploaded artifact | +| `add-assembly-version` | | `false` | Also set `AssemblyVersion` (use on main branch only) | +| `dotnet-version` | | `10.0.x` | SDK version | +| `os` | | `ubuntu-latest` | Runner OS | + +--- + +### Versioning & Release + +#### `_version.yml` + +Determines the next semver from conventional commits using [`reecetech/version-increment`](https://github.com/reecetech/version-increment). Outputs `version` and `current-version`, and uploads a `version.txt` artifact. + +| Input | Required | Default | Description | +|---|---|---|---| +| `is-release-branch` | Yes | | `true` when running on the release branch (e.g. main) | +| `version-artifact-name` | | `version` | Name for the uploaded artifact | + +--- + +#### `_check-release-eligibility.yml` + +Determines whether a release should be published by inspecting PR labels. A release is skipped only if every merged PR since the last tag carries the `no-release` label. + +| Input | Required | Default | Description | +|---|---|---|---| +| `is-release-branch` | Yes | | `true` when running on the release branch | +| `force-release` | | `false` | Override all label checks and always release | + +| Secret | Required | Description | +|---|---|---| +| `GITHUB_TOKEN` | Yes | Token to query PR labels | + +Outputs: `should-release` (`true` / `false`). + +--- + +#### `_dotnet-publish-nuget.yml` + +Downloads a packed artifact and publishes to GitHub Package Registry (prerelease builds) or NuGet.org (releases). Also cleans up old prerelease packages from GPR. + +| Input | Required | Default | Description | +|---|---|---|---| +| `package-artifact-name` | | `packages` | Must match the artifact name from `_dotnet-build-and-pack.yml` | +| `upload-to-github` | | `false` | Publish to GitHub Package Registry | +| `upload-to-nuget` | | `false` | Publish to NuGet.org | +| `num-github-prerelease-packages-to-keep` | | `50` | How many old GPR prerelease versions to retain | +| `dotnet-version` | | `10.0.x` | SDK version | +| `os` | | `ubuntu-latest` | Runner OS | + +| Secret | Required | Description | +|---|---|---| +| `GITHUB_ACCESS_TOKEN` | | Token for GPR. Required if `upload-to-github` is `true` | +| `NUGET_API_KEY` | | API key for NuGet.org. Required if `upload-to-nuget` is `true` | + +--- + +#### `_github-tag-and-release.yml` + +Creates an annotated git tag and a GitHub release with auto-generated release notes. + +| Input | Required | Default | Description | +|---|---|---|---| +| `version` | Yes | | Version string without `v` prefix (e.g. `1.2.3`) | + +| Secret | Required | Description | +|---|---|---| +| `GITHUB_TOKEN` | Yes | Token to create the tag and release | + +--- + +### Full Orchestration + +#### `_dotnet-build-test-pack-publish-nuget.yml` + +Composes all of the above into a single end-to-end pipeline: + +``` +get-version ─┬─ check-release-eligibility + └─ build-and-test ── pack ── publish ── release (if eligible) +``` + +| Input | Required | Default | Description | +|---|---|---|---| +| `working-directory` | | `.` | Root of the .NET solution | +| `is-release-branch` | Yes | | `true` when running on the release branch | +| `force-release` | | `false` | Force a release regardless of labels | +| `dotnet-version` | | `10.0.x` | SDK version for pack/publish jobs | +| `dotnet-version-matrix` | | | JSON matrix for the build-and-test job | +| `os` | | `ubuntu-latest` | Runner OS | +| `codecov-slug` | | | Codecov repo slug | +| `num-github-prerelease-packages-to-keep` | | `50` | GPR cleanup retention count | + +| Secret | Required | Description | +|---|---|---| +| `GITHUB_ACCESS_TOKEN` | Yes | Token for GPR and release creation | +| `NUGET_API_KEY` | | API key for NuGet.org releases | +| `CODECOV_TOKEN` | | Token for Codecov | + +**Quick-start example** — call from your own workflow: + +```yaml +jobs: + ci-cd: + permissions: + contents: write + packages: write + pull-requests: read + uses: saan800/github/.github/workflows/_dotnet-build-test-pack-publish-nuget.yml@main + with: + working-directory: "./src/MyPackage" + is-release-branch: ${{ github.ref == 'refs/heads/main' }} + secrets: + GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} +``` + +--- + +### PR Automation + +#### `_pr-labeler.yml` + +Validates PR titles against [Conventional Commits](https://www.conventionalcommits.org/) and applies labels automatically. Creates any missing labels on first run. + +| Secret | Required | Description | +|---|---|---| +| `GITHUB_TOKEN` | Yes | Token to read/write PR labels | + +#### `_pr-lint.yml` + +Runs [super-linter](https://github.com/super-linter/super-linter) (C#, YAML, GitHub Actions) against changed files. Optionally runs [cspell](https://cspell.org/) spell-checking. + +| Input | Required | Default | Description | +|---|---|---|---| +| `run-lint` | | `false` | Enable super-linter | +| `cspell-config` | | | Path to cspell config file. Spell-checking is skipped if omitted | + +| Secret | Required | Description | +|---|---|---| +| `GITHUB_TOKEN` | Yes | Token for status checks | + +#### `_dependabot-auto-approve-and-merge.yml` + +Auto-approves and squash-merges Dependabot PRs for non-major updates. Blocks on merge conflicts. + +| Input | Required | Description | +|---|---|---| +| `pr-url` | Yes | URL of the Dependabot PR | + +| Secret | Required | Description | +|---|---|---| +| `GITHUB_TOKEN` | Yes | Token to approve and merge | + +--- + +## Example Projects + +| Project | Description | +|---|---| +| [`examples/MinimalWebApi`](examples/MinimalWebApi) | ASP.NET Core 10 controller-based API with unit and integration tests | +| [`examples/NugetPackages`](examples/NugetPackages) | Two packable class libraries showing the full NuGet publish pipeline | diff --git a/examples/MinimalWebApi/src/MinimalWebApi.Api/Controllers/ItemsController.cs b/examples/MinimalWebApi/src/MinimalWebApi.Api/Controllers/ItemsController.cs index 7928ee1..2c92f9c 100644 --- a/examples/MinimalWebApi/src/MinimalWebApi.Api/Controllers/ItemsController.cs +++ b/examples/MinimalWebApi/src/MinimalWebApi.Api/Controllers/ItemsController.cs @@ -51,10 +51,11 @@ public IActionResult PatchUpdate(int id, [FromBody] PatchItemRequest patchReques [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [EndpointDescription("Deletes the item with the provided id")] public IActionResult Delete(int id) { - store.Delete(id); + if (!store.Delete(id)) return NotFound(); return NoContent(); } } diff --git a/examples/MinimalWebApi/src/MinimalWebApi.Api/Program.cs b/examples/MinimalWebApi/src/MinimalWebApi.Api/Program.cs index c65468e..8e5eb8f 100644 --- a/examples/MinimalWebApi/src/MinimalWebApi.Api/Program.cs +++ b/examples/MinimalWebApi/src/MinimalWebApi.Api/Program.cs @@ -9,7 +9,6 @@ builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddEndpointsApiExplorer(); // Required for OpenAPI // Microsoft’s built-in OpenAPI generator builder.Services.AddOpenApi(o => { @@ -56,9 +55,7 @@ var app = builder.Build(); // Configure the HTTP request pipeline. -var isProduction = app.Environment.EnvironmentName.Contains("prod", StringComparison.OrdinalIgnoreCase); - -if (!isProduction) +if (!app.Environment.IsProduction()) { // Enable OpenAPI JSON and Scalar UI app.MapOpenApi(); diff --git a/examples/MinimalWebApi/src/MinimalWebApi.Api/Schema/Items/Item.cs b/examples/MinimalWebApi/src/MinimalWebApi.Api/Schema/Items/Item.cs index 734e473..4aefbae 100644 --- a/examples/MinimalWebApi/src/MinimalWebApi.Api/Schema/Items/Item.cs +++ b/examples/MinimalWebApi/src/MinimalWebApi.Api/Schema/Items/Item.cs @@ -2,7 +2,7 @@ namespace MinimalWebApi.Api.Schema.Items; public class Item { - public required int Id { get; set; } + public required int Id { get; init; } public required string Name { get; set; } public string? Description { get; set; } } diff --git a/examples/MinimalWebApi/src/MinimalWebApi.Api/Schema/Items/ItemsStore.cs b/examples/MinimalWebApi/src/MinimalWebApi.Api/Schema/Items/ItemsStore.cs index 1f6f84f..0a8c885 100644 --- a/examples/MinimalWebApi/src/MinimalWebApi.Api/Schema/Items/ItemsStore.cs +++ b/examples/MinimalWebApi/src/MinimalWebApi.Api/Schema/Items/ItemsStore.cs @@ -25,7 +25,7 @@ public Item Add(CreateItemRequest request) Name = request.Name, Description = request.Description, }; - _items = [.. _items.Where(x => x.Id != newItem.Id), newItem]; + _items = [.. _items, newItem]; return newItem; } } @@ -45,9 +45,14 @@ public bool Patch(int id, PatchItemRequest request) } } - public void Delete(int id) + public bool Delete(int id) { - lock (_lock) _items = _items.Where(x => x.Id != id).ToList(); + lock (_lock) + { + if (!_items.Any(x => x.Id == id)) return false; + _items = _items.Where(x => x.Id != id).ToList(); + return true; + } } public void Reset() diff --git a/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/Controllers/ItemControllerTests.cs b/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/Controllers/ItemControllerTests.cs index bf13b2e..1ed516d 100644 --- a/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/Controllers/ItemControllerTests.cs +++ b/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/Controllers/ItemControllerTests.cs @@ -94,4 +94,11 @@ public async Task Delete_ShouldRemoveItem() var notFound = await _client.GetAsync($"/api/items/{itemIdToDelete}", _ct); notFound.StatusCode.Should().Be(HttpStatusCode.NotFound); } + + [Fact] + public async Task Delete_ShouldReturn404ForNonExistentItem() + { + var response = await _client.DeleteAsync("/api/items/99999", _ct); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } } diff --git a/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/TestConfig/RemoteApiClientProvider.cs b/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/TestConfig/RemoteApiClientProvider.cs index bb99b4a..78dbe41 100644 --- a/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/TestConfig/RemoteApiClientProvider.cs +++ b/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/TestConfig/RemoteApiClientProvider.cs @@ -1,8 +1,14 @@ namespace MinimalWebApi.Tests.Integration.Api.TestConfig; -public class RemoteApiClientProvider(string baseUrl) : IApiClientProvider +public class RemoteApiClientProvider(string baseUrl) : IApiClientProvider, IDisposable { - private readonly string _baseUrl = baseUrl.TrimEnd('/'); + private readonly HttpClient _client = new() { BaseAddress = new Uri(baseUrl.TrimEnd('/')) }; - public HttpClient CreateClient() => new() { BaseAddress = new Uri(_baseUrl) }; + public HttpClient CreateClient() => _client; + + public void Dispose() + { + _client.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/TestConfig/TestHttpClientFactory.cs b/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/TestConfig/TestHttpClientFactory.cs index e6fd737..66b564c 100644 --- a/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/TestConfig/TestHttpClientFactory.cs +++ b/examples/MinimalWebApi/tests/MinimalWebApi.Tests.Integration.Api/TestConfig/TestHttpClientFactory.cs @@ -56,5 +56,6 @@ public void Dispose() { if (Provider is IDisposable disposable) disposable.Dispose(); + GC.SuppressFinalize(this); } } diff --git a/examples/NugetPackages/Directory.Build.props b/examples/NugetPackages/Directory.Build.props index 06b15cb..8b59cb4 100644 --- a/examples/NugetPackages/Directory.Build.props +++ b/examples/NugetPackages/Directory.Build.props @@ -32,8 +32,9 @@ + true + true false - true false false