diff --git a/.github/actions/git-identity/action.yml b/.github/actions/git-identity/action.yml new file mode 100644 index 00000000..cb4d094a --- /dev/null +++ b/.github/actions/git-identity/action.yml @@ -0,0 +1,75 @@ +name: Resolve git identity + +description: > + Resolves the identity of the person who triggered the run, so release commits + and tags are attributed to them rather than to an anonymous bot. + +inputs: + actor: + description: 'GitHub login to attribute the release to.' + required: false + default: ${{ github.triggering_actor }} + token: + description: 'Token used to look the actor up.' + required: false + default: ${{ github.token }} + +outputs: + actor-name: + description: 'Name to use for the triggering user, or the bot if unresolvable.' + value: ${{ steps.resolve.outputs.actor-name }} + actor-email: + description: 'No-reply email for the triggering user, or the bot if unresolvable.' + value: ${{ steps.resolve.outputs.actor-email }} + bot-name: + description: 'Name to record as the committer of automated commits.' + value: ${{ steps.resolve.outputs.bot-name }} + bot-email: + description: 'Email to record as the committer of automated commits.' + value: ${{ steps.resolve.outputs.bot-email }} + +runs: + using: "composite" + steps: + - name: Resolve identity + shell: bash + id: resolve + env: + ACTOR: ${{ inputs.actor }} + GH_TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + + # 41898282 is the fixed id of the github-actions[bot] account. + bot_name='github-actions[bot]' + bot_email='41898282+github-actions[bot]@users.noreply.github.com' + + actor_name="$bot_name" + actor_email="$bot_email" + + # Attribution is a nicety, so anything unresolvable here falls back to the + # bot rather than failing the release. A run started by a schedule has no + # actor, and a login can belong to a deleted account or be a bot itself. + if [[ -z "$ACTOR" ]]; then + echo 'No triggering actor; attributing this release to the bot.' + else + # GitHub links a commit to an account through the numeric user id in the + # no-reply address, so the login alone is not enough. + actor_id=$(gh api "users/${ACTOR}" --jq '.id' 2>/dev/null || true) + + # A failed call can still print an error body to stdout, so require a + # bare number rather than just a non-empty value. + if [[ ! "$actor_id" =~ ^[0-9]+$ ]]; then + echo "::warning::Could not resolve a user id for '${ACTOR}'; attributing this release to the bot." + else + actor_name="$ACTOR" + actor_email="${actor_id}+${ACTOR}@users.noreply.github.com" + echo "Attributing this release to ${ACTOR} (id ${actor_id})" + fi + fi + + echo "actor-name=${actor_name}" >> "$GITHUB_OUTPUT" + echo "actor-email=${actor_email}" >> "$GITHUB_OUTPUT" + + echo "bot-name=${bot_name}" >> "$GITHUB_OUTPUT" + echo "bot-email=${bot_email}" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/next-version/action.yml b/.github/actions/next-version/action.yml new file mode 100644 index 00000000..5e268e97 --- /dev/null +++ b/.github/actions/next-version/action.yml @@ -0,0 +1,88 @@ +name: Resolve next version + +description: 'Reads the current SDK version and resolves the next one for the given strategy.' + +inputs: + version-strategy: + description: 'patch, minor, major or explicit.' + required: true + version: + description: 'The exact version to release (no "v" prefix). Only used when version-strategy is "explicit".' + required: false + default: '' + +outputs: + current-version: + description: 'The version currently committed to the repo, without a "v" prefix.' + value: ${{ steps.resolve.outputs.current-version }} + next-version: + description: 'The resolved next version, without a "v" prefix.' + value: ${{ steps.resolve.outputs.next-version }} + next-tag: + description: 'The resolved next version, with a "v" prefix.' + value: ${{ steps.resolve.outputs.next-tag }} + +runs: + using: "composite" + steps: + - name: Resolve version + shell: bash + id: resolve + # Passed as env rather than interpolated into the script so the values + # are treated strictly as data. + env: + VERSION_STRATEGY: ${{ inputs.version-strategy }} + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + # constructor.io.csproj holds the that ships to NuGet, so it is + # the source of truth for what is currently released. + current=$(sed -n 's|.*\(.*\).*|\1|p' src/constructor.io/constructor.io.csproj | head -1) + + if [[ ! "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Could not read a semver from src/constructor.io/constructor.io.csproj (got '$current')" + exit 1 + fi + + IFS=. read -r major minor patch <<< "$current" + + case "$VERSION_STRATEGY" in + major) + next="$((major + 1)).0.0" + ;; + minor) + next="${major}.$((minor + 1)).0" + ;; + patch) + next="${major}.${minor}.$((patch + 1))" + ;; + explicit) + next=$(echo "$REQUESTED_VERSION" | tr -d 'v') + + if [[ ! "$next" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version-strategy is 'explicit' but 'version' is not a semver value (got '$REQUESTED_VERSION')" + exit 1 + fi + ;; + *) + echo "::error::Unknown version-strategy '$VERSION_STRATEGY'" + exit 1 + ;; + esac + + if [[ "$next" == "$current" ]]; then + echo "::error::Next version ($next) matches the current version. Nothing to release." + exit 1 + fi + + if git rev-parse -q --verify "refs/tags/v${next}" >/dev/null; then + echo "::error::Tag v${next} already exists." + exit 1 + fi + + echo "Bumping $current -> $next" + + echo "current-version=$current" >> "$GITHUB_OUTPUT" + echo "next-version=$next" >> "$GITHUB_OUTPUT" + echo "next-tag=v$next" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/set-version/action.yml b/.github/actions/set-version/action.yml new file mode 100644 index 00000000..476e2ecf --- /dev/null +++ b/.github/actions/set-version/action.yml @@ -0,0 +1,75 @@ +name: Set version + +description: 'Writes a new version into every file that tracks the SDK version.' + +inputs: + version: + description: 'The new version, without a "v" prefix.' + required: true + +runs: + using: "composite" + steps: + - name: Write version to all tracked files + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + version="$VERSION" + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::'version' is not a semver value (got '$version')" + exit 1 + fi + + # The client sends this to the API as the version identifier. + sed -i -E "s|(private readonly string Version = \")cionet-[0-9]+\.[0-9]+\.[0-9]+(\";)|\1cionet-${version}\2|" \ + src/constructor.io/client/ConstructorIO.cs + + # is what NuGet publishes; is kept in step with it. + sed -i -E "s|()[0-9]+\.[0-9]+\.[0-9]+()|\1${version}\2|" \ + src/constructor.io/constructor.io.csproj + sed -i -E "s|()[0-9]+\.[0-9]+\.[0-9]+()|\1${version}\2|" \ + src/constructor.io/constructor.io.csproj + sed -i -E "s|()[0-9]+\.[0-9]+\.[0-9]+()|\1${version}\2|" \ + src/Constructorio_NET.Tests/Constructorio_NET.Tests.csproj + + sed -i -E "s|(version = )[0-9]+\.[0-9]+\.[0-9]+|\1${version}|" \ + src/Constructorio_Net.sln + + - name: Verify no stale versions remain + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + version="$VERSION" + + # Every one of these must now report the new version, otherwise a sed + # above silently matched nothing and we would ship a half-bumped release. + expected=( + "src/constructor.io/client/ConstructorIO.cs:cionet-${version}" + "src/constructor.io/constructor.io.csproj:${version}" + "src/constructor.io/constructor.io.csproj:${version}" + "src/Constructorio_NET.Tests/Constructorio_NET.Tests.csproj:${version}" + "src/Constructorio_Net.sln:version = ${version}" + ) + + failed=0 + + for entry in "${expected[@]}"; do + file="${entry%%:*}" + needle="${entry#*:}" + + if grep -qF "$needle" "$file"; then + echo " ok $file -> $needle" + else + echo "::error file=$file::Expected to find '$needle' after the version bump" + failed=1 + fi + done + + exit "$failed" diff --git a/.github/workflows/create-github-release.yml b/.github/workflows/create-github-release.yml new file mode 100644 index 00000000..bae76d7d --- /dev/null +++ b/.github/workflows/create-github-release.yml @@ -0,0 +1,44 @@ +name: 'Publish: Create GitHub release' + +# We need these permissions to create new GitHub releases. +permissions: + contents: write + +on: + workflow_call: + inputs: + version: + type: string + description: 'Tag to create the release from, including the "v" prefix.' + required: true + title: + type: string + description: 'Release title.' + required: true + + # This workflow is intended to be called from the primary publish workflow + # automatically. The manual trigger is maintained for partially failed workflows + # or edge cases that require this be run independently. + workflow_dispatch: + inputs: + version: + type: string + description: 'Tag to create the release from, including the "v" prefix.' + required: true + title: + type: string + description: 'Release title.' + required: true + +jobs: + create-github-release: + name: Create GitHub release + runs-on: ubuntu-latest + steps: + - name: Create release + uses: ncipollo/release-action@6c75be85e571768fa31b40abf38de58ba0397db5 # v1.13.0 + with: + name: ${{ inputs.title }} + tag: ${{ inputs.version }} + token: ${{ github.token }} + generateReleaseNotes: true diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml new file mode 100644 index 00000000..eafceb30 --- /dev/null +++ b/.github/workflows/generate-docs.yml @@ -0,0 +1,136 @@ +name: 'Publish: Generate documentation' + +# We need these permissions to commit the regenerated docs back to the branch. +permissions: + contents: write + +on: + workflow_call: + inputs: + ref: + type: string + description: 'Branch or ref to generate the documentation from.' + required: false + default: '' + commit: + type: boolean + description: 'Commit the regenerated docs. When false, they are only uploaded as an artifact.' + required: false + default: true + commit-message: + type: string + description: 'Commit message to use when the docs change.' + required: false + default: 'Update documentation' + outputs: + docs-changed: + description: 'Whether regenerating the docs produced any change.' + value: ${{ jobs.generate-docs.outputs.docs-changed }} + + # This workflow is intended to be called from the primary publish workflow + # automatically. The manual trigger is maintained for refreshing the docs on + # their own, outside of a release. + workflow_dispatch: + inputs: + commit: + type: boolean + description: 'Commit the regenerated docs. When false, they are only uploaded as an artifact.' + required: false + default: true + commit-message: + type: string + description: 'Commit message to use when the docs change.' + required: false + default: 'Update documentation' + +env: + # Pinned, because the generator version is baked into every page footer and a + # different version reflows all of docs/. Keep this in step with the version + # the docs were last generated with. + DOXYGEN_VERSION: '1.15.0' + +jobs: + generate-docs: + name: Generate documentation + runs-on: ubuntu-latest + + outputs: + docs-changed: ${{ steps.commit.outputs.docs-changed }} + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Install Doxygen + run: | + set -euo pipefail + + # The apt package lags well behind, so pull the pinned release directly. + url="https://www.doxygen.nl/files/doxygen-${DOXYGEN_VERSION}.linux.bin.tar.gz" + + curl --fail --silent --show-error --location "$url" --output doxygen.tar.gz + tar --extract --gzip --file doxygen.tar.gz + + sudo install "doxygen-${DOXYGEN_VERSION}/bin/doxygen" /usr/local/bin/doxygen + rm -rf doxygen.tar.gz "doxygen-${DOXYGEN_VERSION}" + + doxygen --version + + - name: Generate documentation + run: | + set -euo pipefail + + # The committed Doxyfile leaves the paths below empty so they can be + # pointed at a local checkout. Rather than editing it, append the CI + # values — for repeated keys Doxygen honours the last one it reads. + { + cat Doxyfile + echo 'INPUT = src/constructor.io src/Constructorio_NET.Tests README.md' + echo 'OUTPUT_DIRECTORY = docs' + echo 'HTML_OUTPUT = .' + } > Doxyfile.ci + + doxygen Doxyfile.ci + + rm Doxyfile.ci + + - name: Upload documentation artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs + + - name: Resolve git identity + if: ${{ inputs.commit }} + uses: ./.github/actions/git-identity + id: identity + + - name: Commit documentation + id: commit + if: ${{ inputs.commit }} + # The person who ran the workflow is the author; the bot is only the + # committer. Passed as env rather than interpolated into the script, so a + # message containing quotes cannot break or inject into the commands. + env: + GIT_AUTHOR_NAME: ${{ steps.identity.outputs.actor-name }} + GIT_AUTHOR_EMAIL: ${{ steps.identity.outputs.actor-email }} + GIT_COMMITTER_NAME: ${{ steps.identity.outputs.bot-name }} + GIT_COMMITTER_EMAIL: ${{ steps.identity.outputs.bot-email }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + TARGET_BRANCH: ${{ inputs.ref || github.ref_name }} + run: | + set -euo pipefail + + git add docs + + if git diff --cached --quiet; then + echo 'Documentation is already up to date.' + echo 'docs-changed=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + + git commit -m "$COMMIT_MESSAGE" + git push origin "HEAD:$TARGET_BRANCH" + + echo 'docs-changed=true' >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 00000000..e6b7997d --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,89 @@ +name: 'Publish: Publish to NuGet' + +permissions: + contents: read + +on: + workflow_call: + inputs: + version: + type: string + description: 'The version to publish, with or without a "v" prefix.' + required: true + ref: + type: string + description: 'Branch or ref to build the package from.' + required: false + default: '' + + # This workflow is intended to be called from the primary publish workflow + # automatically. The manual trigger is maintained for partially failed workflows + # or edge cases that require this be run independently. + workflow_dispatch: + inputs: + version: + type: string + description: 'The version to publish, with or without a "v" prefix.' + required: true + ref: + type: string + description: 'Branch or ref to build the package from.' + required: false + default: '' + +jobs: + publish-to-nuget: + name: Publish to NuGet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + + - uses: actions/setup-dotnet@v4 + with: + # The package targets netstandard2.0; the test project targets net8.0. + dotnet-version: '8.0.x' + + - name: Resolve version + id: version + # Passed as env rather than interpolated into the script: this workflow + # can be dispatched directly, so the input has not been through the + # orchestrator's validation and must be treated strictly as data. + env: + REQUESTED_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + version="${REQUESTED_VERSION#v}" + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::'version' is not a semver value (got '$REQUESTED_VERSION')" + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Build and pack + run: ./build.sh Release --clean --test + + - name: Verify packed version matches + run: | + set -euo pipefail + + expected="constructor.io.${{ steps.version.outputs.version }}.nupkg" + + if [[ ! -f "artifacts/$expected" ]]; then + echo "::error::Expected artifacts/$expected. Found: $(ls artifacts 2>/dev/null || echo 'nothing')" + exit 1 + fi + + echo "Found artifacts/$expected" + + - name: Publish to NuGet + run: | + set -euo pipefail + + dotnet nuget push "artifacts/constructor.io.${{ steps.version.outputs.version }}.nupkg" \ + --source 'https://api.nuget.org/v3/index.json' \ + --api-key "${{ secrets.NUGET_API_KEY }}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..454a9a7c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,171 @@ +name: Publish + +# Run this from the Actions tab after merging a PR to main. + +permissions: + contents: write + +on: + # This allows us to run this workflow manually from the Actions tab + workflow_dispatch: + inputs: + version-strategy: + type: choice + description: 'Version strategy' + required: true + options: + - 'patch' + - 'minor' + - 'major' + - 'explicit' + default: minor + version: + type: string + description: 'Exact version to release, e.g. 4.5.0. Only used when the strategy is "explicit".' + required: false + default: '' + title: + type: string + description: 'Release title, e.g. "Add recommendations functionality"' + required: true + publish-to-nuget: + type: boolean + description: 'Publish the package to NuGet.' + required: false + default: true + +concurrency: + group: deploy + cancel-in-progress: false + +jobs: + bump_version: + name: Bump version + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + # Tags are needed so the next-version action can detect a version that + # has already been released; the default checkout fetches none. + fetch-tags: true + fetch-depth: 0 + + - name: Fail if not releasing from the default branch + run: | + set -euo pipefail + + if [[ "${{ github.ref_name }}" != "${{ github.event.repository.default_branch }}" ]]; then + echo "::error::Releases must be cut from ${{ github.event.repository.default_branch }}, got ${{ github.ref_name }}." + exit 1 + fi + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Resolve git identity + uses: ./.github/actions/git-identity + id: identity + + - name: Resolve next version + uses: ./.github/actions/next-version + id: next-version + with: + version-strategy: ${{ inputs.version-strategy }} + version: ${{ inputs.version }} + + - name: Set new version + uses: ./.github/actions/set-version + with: + version: ${{ steps.next-version.outputs.next-version }} + + - name: Build and test at the new version + run: ./build.sh Release --clean --test + + - name: Commit version bump + # The person who ran the workflow is the author; the bot is only the + # committer, which is what actually performed the push. + env: + GIT_AUTHOR_NAME: ${{ steps.identity.outputs.actor-name }} + GIT_AUTHOR_EMAIL: ${{ steps.identity.outputs.actor-email }} + GIT_COMMITTER_NAME: ${{ steps.identity.outputs.bot-name }} + GIT_COMMITTER_EMAIL: ${{ steps.identity.outputs.bot-email }} + TARGET_BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + + git add src + git commit -m 'Version Bump' + git push origin "HEAD:$TARGET_BRANCH" + + outputs: + version: ${{ steps.next-version.outputs.next-version }} + tag: ${{ steps.next-version.outputs.next-tag }} + + # The generated docs do not embed the version (PROJECT_NUMBER is unset, and + # the version field is private), so this runs here only so that any doc + # changes from the merged code land before the tag is cut. + generate_docs: + name: Generate documentation + needs: bump_version + uses: ./.github/workflows/generate-docs.yml + with: + ref: ${{ github.ref_name }} + commit: true + commit-message: 'Update documentation for ${{ needs.bump_version.outputs.tag }}' + secrets: inherit + + tag_release: + name: Tag release + needs: [bump_version, generate_docs] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + # An annotated tag is created here, so the commit being tagged and the + # existing tags both need to be present locally. + fetch-tags: true + fetch-depth: 0 + + - name: Resolve git identity + uses: ./.github/actions/git-identity + id: identity + + - name: Create and push tag + # An annotated tag takes its tagger from the committer, not the author, + # so the triggering user goes in GIT_COMMITTER_* here for the release to + # be attributed to them. The values are passed as env rather than + # interpolated, so a title containing quotes cannot break the commands. + env: + GIT_COMMITTER_NAME: ${{ steps.identity.outputs.actor-name }} + GIT_COMMITTER_EMAIL: ${{ steps.identity.outputs.actor-email }} + TAG: ${{ needs.bump_version.outputs.tag }} + TITLE: ${{ inputs.title }} + run: | + set -euo pipefail + + # Tag the version bump and docs commits pushed by the jobs above. + git tag -a "$TAG" -m "$TITLE" + git push origin "$TAG" + + create_github_release: + name: Create GitHub release + needs: [bump_version, tag_release] + uses: ./.github/workflows/create-github-release.yml + with: + version: ${{ needs.bump_version.outputs.tag }} + title: ${{ inputs.title }} + secrets: inherit + + publish_to_nuget: + name: Publish to NuGet + needs: [bump_version, create_github_release] + if: ${{ inputs.publish-to-nuget }} + uses: ./.github/workflows/publish-nuget.yml + with: + version: ${{ needs.bump_version.outputs.version }} + ref: ${{ needs.bump_version.outputs.tag }} + secrets: inherit diff --git a/README.md b/README.md index cddd8757..5b3268a1 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,22 @@ The build script will create a NuGet package in the `./artifacts` directory. - dotnet husky run -g coverage ## Publishing -The project includes a publish script to push packages to NuGet: +Releases are cut by the **Publish** GitHub Actions workflow. Do not bump the +version by hand — the workflow does it, and doing both risks publishing twice. + +After merging a PR to `main`: +1. Open the Actions tab and run the **Publish** workflow against `main` +2. Pick a version strategy (`patch`/`minor`/`major`, or `explicit` plus an exact + version) and enter a release title +3. The workflow bumps the version, regenerates the documentation, tags the + release, creates the GitHub release, and pushes the package to NuGet + +Leave `publish-to-nuget` unchecked for a dry run that stops short of NuGet. + +### Publishing manually +`./publish.sh` remains as a fallback for when the workflow is unavailable or a +run fails partway through. It prompts for confirmation, so it cannot be used +from CI. ```bash # Set your NuGet API key @@ -404,5 +419,13 @@ The publish script will: 3. Push to NuGet.org 4. Clean up the artifacts folder on success +Publishing by hand means the version bump, tag, GitHub release and regenerated +documentation are all your responsibility — see the steps the workflow runs +above. + ## Documentation - Documentation generated by [Doxygen](https://doxygen.nl/download.html) for Mac OS X 10.14 and later +- `docs/` is regenerated automatically as part of the **Publish** workflow. To + refresh it on its own, run the **Publish: Generate documentation** workflow + from the Actions tab; uncheck `commit` to inspect the output as an artifact + without committing it.