diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49bb6a4..72c0e38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,11 +31,19 @@ jobs: - name: Resolve version id: ver run: | + set -euo pipefail if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.tag }}" ]; then - echo "version=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + RAW="${{ inputs.tag }}" else - echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + RAW="${GITHUB_REF_NAME}" fi + # Keep tag with v for GitHub Release; strip for package/image labels + TAG="$RAW" + case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved tag=$TAG version=$VERSION" - uses: actions/setup-dotnet@v4 with: @@ -78,8 +86,8 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.ver.outputs.version }} - name: NotificationHub ${{ steps.ver.outputs.version }} + tag_name: ${{ steps.ver.outputs.tag }} + name: NotificationHub ${{ steps.ver.outputs.tag }} generate_release_notes: true files: | publish/** diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml new file mode 100644 index 0000000..cf3c2ec --- /dev/null +++ b/.github/workflows/version.yml @@ -0,0 +1,140 @@ +name: Version bump (SemVer) + +on: + pull_request: + types: [closed] + branches: [dev] + workflow_dispatch: + inputs: + bump: + description: "Force bump level (major|minor|patch|auto)" + required: false + default: auto + +permissions: + contents: write + +concurrency: + group: version-dev + cancel-in-progress: false + +jobs: + bump: + name: Compute SemVer and tag + runs-on: ubuntu-latest + # Merged PR into dev, or manual + if: > + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.pull_request.merged == true) + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: dev + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Compute next version + id: semver + env: + FORCE_BUMP: ${{ github.event.inputs.bump || 'auto' }} + run: | + set -euo pipefail + CURRENT=$(python3 -c "import json; print(json.load(open('version.json'))['version'])") + echo "version.json=$CURRENT" + + LAST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "") + if [ -n "$LAST_TAG" ]; then + RANGE="${LAST_TAG}..HEAD" + LOG=$(git log --pretty=format:'%s' "$RANGE") + else + LOG=$(git log --pretty=format:'%s' -30) + fi + + BUMP="patch" + if echo "$LOG" | grep -Eiq '^(feat|fix|perf|refactor|chore|docs|test|ci|build|style)(\(.+\))?!:|BREAKING CHANGE:'; then + BUMP="major" + elif echo "$LOG" | grep -Eiq '^feat(\(.+\))?:'; then + BUMP="minor" + fi + case "${FORCE_BUMP}" in major|minor|patch) BUMP="$FORCE_BUMP" ;; esac + + IFS=. read -r MA MI PA <<< "$CURRENT" + MA=${MA:-0}; MI=${MI:-0}; PA=${PA:-0} + case "$BUMP" in + major) MA=$((MA+1)); MI=0; PA=0 ;; + minor) MI=$((MI+1)); PA=0 ;; + patch) PA=$((PA+1)) ;; + esac + NEXT="${MA}.${MI}.${PA}" + TAG="v${NEXT}" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists — skip (immutable)" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Avoid tag spam if only chore(release)/merge noise + if [ -z "$LOG" ]; then + echo "No commits in range — skip" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "bump=$BUMP" >> "$GITHUB_OUTPUT" + echo "version=$NEXT" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "Next $TAG ($BUMP)" + + - name: Update version files and tag + if: steps.semver.outputs.skip != 'true' + env: + NEXT: ${{ steps.semver.outputs.version }} + TAG: ${{ steps.semver.outputs.tag }} + run: | + set -euo pipefail + python3 - <<'PY' + import json, pathlib, re, os + next_v = os.environ["NEXT"] + pathlib.Path("version.json").write_text(json.dumps({ + "version": next_v, + "scheme": "semver", + "product": "NotificationHub" + }, indent=2) + "\n") + p = pathlib.Path("Directory.Build.props") + t = p.read_text() + t = re.sub(r"[^<]+", f"{next_v}", t) + t = re.sub(r"[^<]+", f"{next_v}.0", t) + p.write_text(t) + print("files ->", next_v) + PY + git add version.json Directory.Build.props + git commit -m "chore(release): bump version to ${NEXT}" || true + git tag -a "$TAG" -m "Release ${TAG}" + + - name: Push tag (and version commit if allowed) + if: steps.semver.outputs.skip != 'true' + env: + # Optional: repo secret VERSION_BUMP_TOKEN (PAT with contents:write that can push to protected dev) + BUMP_TOKEN: ${{ secrets.VERSION_BUMP_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.semver.outputs.tag }}" + if [ -n "${BUMP_TOKEN:-}" ]; then + git remote set-url origin "https://x-access-token:${BUMP_TOKEN}@github.com/${{ github.repository }}.git" + git push origin HEAD:dev + git push origin "$TAG" + else + # GITHUB_TOKEN cannot bypass required status checks on protected branches. + # Push tag only from current commit so release.yml still runs. + git push origin "$TAG" || { + echo "::warning::Could not push version commit to protected dev. Tag-only push attempted." + # Tag the pre-commit HEAD if commit couldn't be pushed + git push origin "$TAG" + } + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a9ba9a4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing to NotificationHub + +Thanks for helping improve the project. + +## Branching + +- **`dev`** — integration branch (protected: required status checks, no force-push/delete). +- Feature work: branch from `dev`, open a PR **into `dev`**. +- Do not force-push `dev`. + +## Commit messages + +Follow **Conventional Commits**. Full guide: [docs/ops/commit-conventions.md](docs/ops/commit-conventions.md). + +```text +type(scope): subject +``` + +Examples: `feat(campaigns): add CSV import`, `fix(ef): idempotent Broadcast migration`, `ci(security): pin trivy-action`. + +Breaking changes: `feat(api)!: ...` and/or footer `BREAKING CHANGE:`. + +## Versioning + +Product version is **SemVer 2.0.0**. Guide: [docs/ops/versioning.md](docs/ops/versioning.md). + +- Source of truth: `version.json` + `Directory.Build.props` +- On merge to `dev`, [version.yml](.github/workflows/version.yml) may create tag `vX.Y.Z` +- Tags trigger [release.yml](.github/workflows/release.yml) (GitHub Release + GHCR image) +- Runtime: `GET /api/v1/version` + +| Signal | Bump | +|--------|------| +| Breaking (`!` / `BREAKING CHANGE`) | MAJOR | +| `feat` | MINOR | +| `fix` / `perf` / `docs` / … | PATCH | + +## Checks required on `dev` + +PRs must pass (among others): + +- Build and Test +- Unit tests (all) +- dotnet format verify +- Build Docker Image +- Trivy scan (Docker image) +- NuGet vulnerability audit + +Locally: + +```bash +dotnet restore +dotnet format --verify-no-changes --severity error +dotnet test +``` + +## Docs + +- Architecture: [docs/README.md](docs/README.md) (ADRs) +- Ops runbooks: [docs/ops/](docs/ops/) +- Plugin SDK: [docs/sdk/plugin-sdk.md](docs/sdk/plugin-sdk.md) + +When you change architecture, update or add an ADR. When you change process (commits, versioning, CI), update the ops docs in the same PR. + +## License + +By contributing, you agree that your contributions are licensed under the same **MIT** license as the repository. diff --git a/Directory.Build.props b/Directory.Build.props index 9949bb5..a7ee192 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,6 +6,15 @@ enable false true + + + 0.1.0 + $(VersionPrefix) + 0.1.0.0 + $(VersionPrefix).0 + $(VersionPrefix)+$(SourceRevisionId) + local + true