From e026c0c454e82be1a0e96ef2007b81554c3060f2 Mon Sep 17 00:00:00 2001 From: mudaafi Date: Thu, 22 Oct 2026 04:43:25 +0800 Subject: [PATCH 1/3] Add release workflow for versioning and publishing Replaces the manual versioning steps with a Publish workflow, run from the Actions tab after merging a PR to main. It bumps the version, regenerates the documentation, tags the release, creates the GitHub release and pushes the package to NuGet. Leaving publish-to-nuget unchecked stops it short of NuGet. Documentation generation is a separate workflow so it can be run on its own, and publishing and release creation are reusable so a partially failed run can be resumed a step at a time. Release commits are authored by whoever triggered the run, and the tag records them as tagger; unresolvable actors fall back to the bot rather than failing. publish.sh is kept as a manual fallback, since it prompts for confirmation and cannot run in CI. README now documents the workflow as the primary path. Co-Authored-By: Claude Opus 5 --- .github/actions/git-identity/action.yml | 75 +++++++++ .github/actions/next-version/action.yml | 88 ++++++++++ .github/actions/set-version/action.yml | 75 +++++++++ .github/workflows/create-github-release.yml | 44 +++++ .github/workflows/generate-docs.yml | 136 ++++++++++++++++ .github/workflows/publish-nuget.yml | 78 +++++++++ .github/workflows/publish.yml | 171 ++++++++++++++++++++ README.md | 25 ++- 8 files changed, 691 insertions(+), 1 deletion(-) create mode 100644 .github/actions/git-identity/action.yml create mode 100644 .github/actions/next-version/action.yml create mode 100644 .github/actions/set-version/action.yml create mode 100644 .github/workflows/create-github-release.yml create mode 100644 .github/workflows/generate-docs.yml create mode 100644 .github/workflows/publish-nuget.yml create mode 100644 .github/workflows/publish.yml 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..03cd268f --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,78 @@ +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 + run: | + set -euo pipefail + + version=$(echo "${{ inputs.version }}" | tr -d 'v') + 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. From 10a8ffc9b1a3ba0ce50206734bd0e2dbe838f3d2 Mon Sep 17 00:00:00 2001 From: mudaafi Date: Thu, 22 Oct 2026 04:58:02 +0800 Subject: [PATCH 2/3] Harden release workflow against PR review findings Validate the version in publish-nuget before use. The workflow can be dispatched directly, so the input has not been through the orchestrator's checks; it is now passed as env and required to be semver, so shell metacharacters cannot reach the script. Pin the release to an immutable revision. Each job previously re-resolved the branch, so a merge landing mid-release could enter the tag without having been built or validated. The bump commit's SHA is now threaded through documentation generation to the tag, and pushes use --force-with-lease so a branch that moved underneath stops the release instead of absorbing it. Co-Authored-By: Claude Opus 5 --- .github/workflows/generate-docs.yml | 20 +++++++++++++++++--- .github/workflows/publish-nuget.yml | 13 ++++++++++++- .github/workflows/publish.yml | 22 +++++++++++++++++++--- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index eafceb30..5d549a04 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -9,7 +9,12 @@ on: inputs: ref: type: string - description: 'Branch or ref to generate the documentation from.' + description: 'Branch, tag or commit SHA to generate the documentation from.' + required: false + default: '' + target-branch: + type: string + description: 'Branch to push the docs commit to. Defaults to the branch this ran on.' required: false default: '' commit: @@ -26,6 +31,9 @@ on: docs-changed: description: 'Whether regenerating the docs produced any change.' value: ${{ jobs.generate-docs.outputs.docs-changed }} + sha: + description: 'The docs commit, or the ref that was generated from when nothing changed.' + value: ${{ jobs.generate-docs.outputs.sha }} # This workflow is intended to be called from the primary publish workflow # automatically. The manual trigger is maintained for refreshing the docs on @@ -56,6 +64,7 @@ jobs: outputs: docs-changed: ${{ steps.commit.outputs.docs-changed }} + sha: ${{ steps.commit.outputs.sha }} steps: - uses: actions/checkout@v4 @@ -118,7 +127,7 @@ jobs: 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 }} + TARGET_BRANCH: ${{ inputs.target-branch || github.ref_name }} run: | set -euo pipefail @@ -127,10 +136,15 @@ jobs: if git diff --cached --quiet; then echo 'Documentation is already up to date.' echo 'docs-changed=false' >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" exit 0 fi git commit -m "$COMMIT_MESSAGE" - git push origin "HEAD:$TARGET_BRANCH" + + # --force-with-lease would reject a push built on a stale checkout, + # which is what we want: the caller tags this exact commit. + git push --force-with-lease origin "HEAD:$TARGET_BRANCH" echo 'docs-changed=true' >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml index 03cd268f..e6b7997d 100644 --- a/.github/workflows/publish-nuget.yml +++ b/.github/workflows/publish-nuget.yml @@ -47,10 +47,21 @@ jobs: - 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=$(echo "${{ inputs.version }}" | tr -d 'v') + 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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 454a9a7c..af8599e9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -85,6 +85,7 @@ jobs: run: ./build.sh Release --clean --test - name: Commit version bump + id: commit # The person who ran the workflow is the author; the bot is only the # committer, which is what actually performed the push. env: @@ -98,11 +99,20 @@ jobs: git add src git commit -m 'Version Bump' - git push origin "HEAD:$TARGET_BRANCH" + + # --force-with-lease rather than a plain push: if the branch moved + # after this job checked it out, the merged code was never built or + # validated here, so the release must stop rather than absorb it. + git push --force-with-lease origin "HEAD:$TARGET_BRANCH" + + # Later jobs check out this exact commit instead of re-resolving the + # branch, so a merge landing mid-release cannot enter the tag. + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" outputs: version: ${{ steps.next-version.outputs.next-version }} tag: ${{ steps.next-version.outputs.next-tag }} + sha: ${{ steps.commit.outputs.sha }} # 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 @@ -112,7 +122,10 @@ jobs: needs: bump_version uses: ./.github/workflows/generate-docs.yml with: - ref: ${{ github.ref_name }} + # Generate from the bump commit, not the branch tip, and push to the + # branch separately. + ref: ${{ needs.bump_version.outputs.sha }} + target-branch: ${{ github.ref_name }} commit: true commit-message: 'Update documentation for ${{ needs.bump_version.outputs.tag }}' secrets: inherit @@ -124,7 +137,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.ref_name }} + # Tag the exact revision this release validated — the docs commit if + # one was made, otherwise the version bump — rather than whatever the + # branch points at now. + ref: ${{ needs.generate_docs.outputs.sha || needs.bump_version.outputs.sha }} # An annotated tag is created here, so the commit being tagged and the # existing tags both need to be present locally. fetch-tags: true From c0b7f347c292576bf053e3b8ca0c4a35d153a599 Mon Sep 17 00:00:00 2001 From: mudaafi Date: Thu, 22 Oct 2026 21:58:52 +0800 Subject: [PATCH 3/3] Revert release SHA pinning to match the PLP publish flow Restores publish.yml and generate-docs.yml to their state before 10a8ffc. Checking out the bump commit by SHA left no origin/ tracking ref, so the bare --force-with-lease docs push failed with "stale info" whenever the docs changed, blocking the tag and NuGet publish. Rather than add an explicit lease on top, drop the SHA threading entirely and follow constructorio-ui-plp: check out the branch by name, push plainly, tag the branch tip. A merge landing mid-release can enter the tag; that is accepted, as it is for the UI repos. The publish-nuget input validation from 10a8ffc is kept. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/generate-docs.yml | 20 +++----------------- .github/workflows/publish.yml | 22 +++------------------- 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 5d549a04..eafceb30 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -9,12 +9,7 @@ on: inputs: ref: type: string - description: 'Branch, tag or commit SHA to generate the documentation from.' - required: false - default: '' - target-branch: - type: string - description: 'Branch to push the docs commit to. Defaults to the branch this ran on.' + description: 'Branch or ref to generate the documentation from.' required: false default: '' commit: @@ -31,9 +26,6 @@ on: docs-changed: description: 'Whether regenerating the docs produced any change.' value: ${{ jobs.generate-docs.outputs.docs-changed }} - sha: - description: 'The docs commit, or the ref that was generated from when nothing changed.' - value: ${{ jobs.generate-docs.outputs.sha }} # This workflow is intended to be called from the primary publish workflow # automatically. The manual trigger is maintained for refreshing the docs on @@ -64,7 +56,6 @@ jobs: outputs: docs-changed: ${{ steps.commit.outputs.docs-changed }} - sha: ${{ steps.commit.outputs.sha }} steps: - uses: actions/checkout@v4 @@ -127,7 +118,7 @@ jobs: GIT_COMMITTER_NAME: ${{ steps.identity.outputs.bot-name }} GIT_COMMITTER_EMAIL: ${{ steps.identity.outputs.bot-email }} COMMIT_MESSAGE: ${{ inputs.commit-message }} - TARGET_BRANCH: ${{ inputs.target-branch || github.ref_name }} + TARGET_BRANCH: ${{ inputs.ref || github.ref_name }} run: | set -euo pipefail @@ -136,15 +127,10 @@ jobs: if git diff --cached --quiet; then echo 'Documentation is already up to date.' echo 'docs-changed=false' >> "$GITHUB_OUTPUT" - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" exit 0 fi git commit -m "$COMMIT_MESSAGE" - - # --force-with-lease would reject a push built on a stale checkout, - # which is what we want: the caller tags this exact commit. - git push --force-with-lease origin "HEAD:$TARGET_BRANCH" + git push origin "HEAD:$TARGET_BRANCH" echo 'docs-changed=true' >> "$GITHUB_OUTPUT" - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index af8599e9..454a9a7c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -85,7 +85,6 @@ jobs: run: ./build.sh Release --clean --test - name: Commit version bump - id: commit # The person who ran the workflow is the author; the bot is only the # committer, which is what actually performed the push. env: @@ -99,20 +98,11 @@ jobs: git add src git commit -m 'Version Bump' - - # --force-with-lease rather than a plain push: if the branch moved - # after this job checked it out, the merged code was never built or - # validated here, so the release must stop rather than absorb it. - git push --force-with-lease origin "HEAD:$TARGET_BRANCH" - - # Later jobs check out this exact commit instead of re-resolving the - # branch, so a merge landing mid-release cannot enter the tag. - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + git push origin "HEAD:$TARGET_BRANCH" outputs: version: ${{ steps.next-version.outputs.next-version }} tag: ${{ steps.next-version.outputs.next-tag }} - sha: ${{ steps.commit.outputs.sha }} # 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 @@ -122,10 +112,7 @@ jobs: needs: bump_version uses: ./.github/workflows/generate-docs.yml with: - # Generate from the bump commit, not the branch tip, and push to the - # branch separately. - ref: ${{ needs.bump_version.outputs.sha }} - target-branch: ${{ github.ref_name }} + ref: ${{ github.ref_name }} commit: true commit-message: 'Update documentation for ${{ needs.bump_version.outputs.tag }}' secrets: inherit @@ -137,10 +124,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - # Tag the exact revision this release validated — the docs commit if - # one was made, otherwise the version bump — rather than whatever the - # branch points at now. - ref: ${{ needs.generate_docs.outputs.sha || needs.bump_version.outputs.sha }} + 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