From 97d9ef24bb9945ea6da782cc768d91bba23967d5 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 16 Aug 2026 02:25:25 +0200 Subject: [PATCH 1/3] feat(build)!: derive the version from git, and adopt the verified release flow The old arrangement created a draft, a human clicked Publish, and only then did the publish workflows run -- so everything that verified a release ran after it was already public. The release is now created as a prerelease and promoted only once everything that can fail has succeeded; the confirmation step is an environment approval on the job that tags, reached after lint and build are already green. The version no longer has to be typed: it is auto-detected from Conventional Commits, and passing one that disagrees needs `force`. `prerelease: rc` cuts a release candidate, verified exactly like a release but never promoted to latest. See RELEASING.md in reqstool/.github for the whole flow. Replaces axion-release with git-dyn-semver. Both derive the version from git tags, so this is not a change of principle -- it is the same plugin the rest of the org's Gradle builds will use, and it applies Conventional Commits bump logic to compute the next version rather than only reading the last tag. Pure JGit, no git CLI required. Verified locally -- tagging 9.9.9 makes `./gradlew printVersion` print exactly 9.9.9; off-tag it yields {next}-{distance}-SNAPSHOT. `./gradlew publishPlugins` builds from the tag itself, so the release flow has no separate build-and-attach step. `java-publish-to-gradle.yml` asserts the resolved version matches the tag before publishing, which catches the one failure that matters here: a shallow clone, which makes the plugin compute a version rather than fail. BREAKING CHANGE: the version is now computed by a different plugin. An off-tag build's version string changes shape (git-dyn-semver's {next}-{distance}-SNAPSHOT, not axion-release's). Tagged builds are unaffected. Signed-off-by: Jimisola Laursen --- .github/workflows/build.yml | 21 ++++ .github/workflows/check_release.yml | 11 -- .github/workflows/publish_plugin_portal.yml | 30 ------ .github/workflows/release.yml | 113 ++++++++++++++++++++ build.gradle | 13 +-- 5 files changed, 138 insertions(+), 50 deletions(-) delete mode 100644 .github/workflows/check_release.yml delete mode 100644 .github/workflows/publish_plugin_portal.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fe2b21..a3e7d09 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,19 @@ on: workflow_call: + inputs: + ref: + description: "Branch, tag or SHA to build. Empty = the caller's ref." + required: false + type: string + default: "" + artifact-name: + description: > + Name to upload the build output under. The release flow runs this + workflow twice in one run -- once on the branch, once on the tag -- and + upload-artifact rejects a duplicate name. + required: false + type: string + default: "dist" workflow_dispatch: push: branches: @@ -21,7 +35,14 @@ jobs: matrix: reqstool-source: [pypi, main] steps: + # Full history and tags: git-dyn-semver derives the version from git state, so a + # shallow clone would build the wrong number rather than fail. - uses: actions/checkout@v7 + with: + persist-credentials: false + fetch-depth: 0 + fetch-tags: true + ref: ${{ inputs.ref || github.ref }} - name: Set up Java uses: actions/setup-java@v5 with: diff --git a/.github/workflows/check_release.yml b/.github/workflows/check_release.yml deleted file mode 100644 index 62fe017..0000000 --- a/.github/workflows/check_release.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Check rules for release -on: - workflow_call: - -jobs: - check-release: - runs-on: ubuntu-latest - steps: - - name: Check branch and tag - if: github.event_name == 'push' && !(github.ref == 'refs/heads/main' && startsWith(github.ref, 'refs/tags/')) - run: exit 1 diff --git a/.github/workflows/publish_plugin_portal.yml b/.github/workflows/publish_plugin_portal.yml deleted file mode 100644 index b544196..0000000 --- a/.github/workflows/publish_plugin_portal.yml +++ /dev/null @@ -1,30 +0,0 @@ - -name: Publish Gradle Plugin to Plugin Portal -on: - release: - types: [created] - -jobs: - check-release: - name: Reuse check release - uses: ./.github/workflows/check_release.yml - build: - name: Reuse build - uses: ./.github/workflows/build.yml - publish: - needs: - - build - - check-release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - name: Set up Java for publishing to Gradle Plugin Portal - uses: actions/setup-java@v5 - with: - java-version: "21" - distribution: "temurin" - - name: Publish plugin to Gradle Plugin Portal - run: ./gradlew publishPlugins - env: - GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} - GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0e09d1b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,113 @@ +name: Release + +# The whole release, start to finish. See RELEASING.md in reqstool/.github for +# what each step does, and for why the release is created as a prerelease rather +# than a draft. + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (Maven, no v prefix), e.g. 1.1.0. Leave empty to auto-detect from Conventional Commits." + required: false + type: string + prerelease: + description: "Publish as a release candidate instead of a release: verified like any release, but never promoted to latest. The number is chosen for you (1.1.0 -> 1.1.0-rc1, then the next)." + required: false + type: choice + options: [none, rc, b, a] + default: none + ref: + description: "Branch to release from. Leave empty for the branch this workflow was dispatched on." + required: false + type: string + force: + description: "Allow a version that disagrees with the auto-detected one." + required: false + type: boolean + default: false + dry-run: + description: "Validate and preview only -- nothing tagged, nothing published." + required: false + type: boolean + default: true + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + prepare: + uses: reqstool/.github/.github/workflows/common-release-prepare.yml@main + permissions: + contents: read + with: + version-format: maven + version: ${{ inputs.version }} + prerelease: ${{ inputs.prerelease }} + ref: ${{ inputs.ref }} + force: ${{ inputs.force }} + dry-run: ${{ inputs.dry-run }} + + # The same checks that guard main, called rather than reimplemented, and run + # before the approval gate so the reviewer approves something already green + # rather than a version string. + checks: + needs: prepare + if: ${{ !inputs.dry-run }} + uses: ./.github/workflows/build.yml + permissions: + contents: read + + # THE APPROVAL GATE -- bound to the `stable` environment, so it sits pending + # until a required reviewer approves it on the run page. + tag: + needs: [prepare, checks] + if: ${{ !inputs.dry-run }} + uses: reqstool/.github/.github/workflows/common-release-tag.yml@main + permissions: + contents: write + with: + version: ${{ needs.prepare.outputs.version }} + ref: ${{ inputs.ref }} + + # `./gradlew publishPlugins` builds from the tag itself, so there is no + # separate build step. `version` makes a disagreement a hard stop before + # anything reaches the portal. + publish-to-plugin-portal: + needs: [prepare, tag] + uses: reqstool/.github/.github/workflows/java-publish-to-gradle.yml@main + permissions: + contents: read + secrets: inherit + with: + target: portal + ref: ${{ needs.prepare.outputs.version }} + version: ${{ needs.prepare.outputs.version }} + environment: stable + + # Last, deliberately. Everything above can fail, and until this runs nothing + # resolving "the latest release" can see what was built -- the release is still + # a prerelease. Promotion itself is one API call against a release that already + # has its artifacts. + # + # The guard is `no job failed`, not the default `every job succeeded`: a release + # candidate deliberately skips the publish jobs that a real release runs, and a + # skipped dependency would otherwise cascade and skip this too -- leaving the + # candidate unpromoted, which is right, and every *real* release unpromoted the + # moment any optional job is skipped, which is not. + # + # `!inputs.dry-run` has to be spelled out for the same reason: on a dry run + # every job above is skipped, and "nothing failed" would otherwise be true. + promote: + needs: [prepare, publish-to-plugin-portal] + if: ${{ !inputs.dry-run && !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + uses: reqstool/.github/.github/workflows/common-release-promote.yml@main + permissions: + contents: write + with: + version: ${{ needs.prepare.outputs.version }} + prerelease: ${{ needs.prepare.outputs.prerelease == 'true' }} diff --git a/build.gradle b/build.gradle index 056bd5c..e37f2b7 100644 --- a/build.gradle +++ b/build.gradle @@ -4,18 +4,13 @@ plugins { id 'maven-publish' id 'io.spring.javaformat' version '0.0.47' id 'com.gradle.plugin-publish' version '2.1.1' - id 'pl.allegro.tech.build.axion-release' version '1.21.3' -} - -scmVersion { - tag { - prefix = '' - } - versionCreator 'simple' + // Sets the project version from git tags using Conventional Commits bump + // logic -- the Gradle counterpart to Nisse on the Maven side, so the tag is + // the only version across the org. See RELEASING.md in reqstool/.github. + id 'io.github.jimisola.git-dyn-semver' version '0.1.2' } group = 'io.github.reqstool' -version = scmVersion.version java { sourceCompatibility = JavaVersion.VERSION_21 From b78e2d27c47b7227753af5da31af909a4c981682 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 16 Aug 2026 02:34:26 +0200 Subject: [PATCH 2/3] fix(ci): point the semantic-PR check at a ref that has the workflow check-semantic-pr.yml pinned common-check-semantic-pr.yml at e1d67194373e4da7ccfdf400f46201f18ca14f23. That commit predates the file: the `common-` rename came later, so the pinned tree has no such workflow. GitHub cannot resolve a workflow_call to a path that does not exist, so the run failed at startup with no jobs -- and because that produces no check run, it never appeared in the PR checks list. PR titles have therefore not been validated here since the pin was written. Following @main, as the other callers do, until the org settles on re-pinning. Signed-off-by: Jimisola Laursen --- .github/workflows/check-semantic-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-semantic-pr.yml b/.github/workflows/check-semantic-pr.yml index b26224b..81a00ea 100644 --- a/.github/workflows/check-semantic-pr.yml +++ b/.github/workflows/check-semantic-pr.yml @@ -10,4 +10,4 @@ permissions: jobs: check: - uses: reqstool/.github/.github/workflows/common-check-semantic-pr.yml@e1d67194373e4da7ccfdf400f46201f18ca14f23 # main 2026-03-07 + uses: reqstool/.github/.github/workflows/common-check-semantic-pr.yml@main From c667fe7708d35937e8c16ad7cae966a6a7c51570 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 16 Aug 2026 08:45:16 +0200 Subject: [PATCH 3/3] fix(release): pass version-format to the tag job common-release-tag.yml now validates the version and the ref itself rather than trusting that prepare validated the same values (CodeQL flagged the privileged checkout on an unvalidated ref in reqstool/.github#66). Validating the version needs to know which format to validate against. Signed-off-by: Jimisola Laursen --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e09d1b..646905f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,7 @@ jobs: contents: write with: version: ${{ needs.prepare.outputs.version }} + version-format: maven ref: ${{ inputs.ref }} # `./gradlew publishPlugins` builds from the tag itself, so there is no