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