From dea3c377107fb84e8d991b8f343df31ef5fbba9e Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 16 Aug 2026 02:25:24 +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. `1.0.0` was hand-edited, so the version in the POM and the version in the tag were two facts that had to be kept in agreement by hand. The Maveniverse Nisse core extension derives it from git state instead (`${nisse.jgit.dynamicVersion}`), matching hatch-vcs and poetry-dynamic-versioning on the Python side: there is now no version string in this repo to edit. Verified locally -- tagging 9.9.9 makes `mvn help:evaluate -Dexpression= project.version` print exactly 9.9.9, and `${project.version}` in the annotationProcessorPath resolves unchanged. `mvn deploy` builds from the tag itself, so the release flow has no separate build-and-attach step; the artifacts Central receives are the ones Nisse stamped. `java-publish-to-maven.yml` asserts the resolved version matches the tag before deploying, which catches the one failure that matters here: a shallow clone, which makes Nisse compute a version rather than fail. Note for whoever cuts the next release: the existing 1.0.0 tag is not an ancestor of main, so until the first release from this flow, off-tag builds report 0.1.0-N-SNAPSHOT rather than 1.0.x-N-SNAPSHOT. Nothing publishes snapshots, and git-cliff reads the tag regardless (it proposes 1.1.0), so this is cosmetic and self-corrects at the first release. BREAKING CHANGE: the POM no longer carries a literal version. A build from a shallow clone or an export without .git resolves a different version than before. Signed-off-by: Jimisola Laursen --- .github/workflows/build.yml | 21 ++++++ .github/workflows/check_release.yml | 11 --- .github/workflows/publish_maven.yml | 38 ---------- .github/workflows/release.yml | 113 ++++++++++++++++++++++++++++ .mvn/extensions.xml | 10 +++ .mvn/maven.config | 1 + pom.xml | 3 +- 7 files changed, 147 insertions(+), 50 deletions(-) delete mode 100644 .github/workflows/check_release.yml delete mode 100644 .github/workflows/publish_maven.yml create mode 100644 .github/workflows/release.yml create mode 100644 .mvn/extensions.xml create mode 100644 .mvn/maven.config diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 304b823..b3c6289 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: @@ -24,7 +38,14 @@ jobs: matrix: reqstool-source: [pypi, main] steps: + # Full history and tags: Nisse 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 JDK 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_maven.yml b/.github/workflows/publish_maven.yml deleted file mode 100644 index fa4a537..0000000 --- a/.github/workflows/publish_maven.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Publish package to the Maven Central Repository -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 - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v7 - - name: Set up Java for publishing to Maven Central Repository - uses: actions/setup-java@v5 - with: - java-version: "21" - distribution: "temurin" - server-id: central - server-username: MAVEN_CENTRAL_USERNAME - server-password: MAVEN_CENTRAL_TOKEN - gpg-private-key: ${{ secrets.REQSTOOL_PRIVATE_GPG_KEY }} - gpg-passphrase: REQSTOOL_PRIVATE_GPG_PASSPHRASE - - name: Publish to the Maven Central Repository - run: mvn clean deploy - env: - MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} - MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} - REQSTOOL_PRIVATE_GPG_PASSPHRASE: ${{ secrets.REQSTOOL_PRIVATE_GPG_PASSPHRASE }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a88583a --- /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 }} + + # `mvn deploy` builds from the tag itself, so there is no separate build step: + # the artifacts it signs and uploads are the ones Nisse stamped from the tag. + # `version` makes a disagreement a hard stop before anything reaches Central. + publish-to-maven-central: + needs: [prepare, tag] + uses: reqstool/.github/.github/workflows/java-publish-to-maven.yml@main + permissions: + contents: read + packages: write + secrets: inherit + with: + 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-maven-central] + 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/.mvn/extensions.xml b/.mvn/extensions.xml new file mode 100644 index 0000000..6787906 --- /dev/null +++ b/.mvn/extensions.xml @@ -0,0 +1,10 @@ + + + + + eu.maveniverse.maven.nisse + extension + 0.9.5 + + diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 0000000..7dc73fb --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1 @@ +-Dnisse.source.jgit.dynamicVersion=true diff --git a/pom.xml b/pom.xml index efe51c4..cd1287a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,8 @@ io.github.reqstool reqstool-java-annotations - 1.0.0 + + ${nisse.jgit.dynamicVersion} jar ${project.artifactId} From 1601af95ff6070c6b6757a4d8943221634801773 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 57142ac..b0ce8b2 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 e525bcaf49dc47c9f6f2ba3cb5c689eb1a08a945 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 16 Aug 2026 08:45:12 +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 a88583a..83c765d 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 }} # `mvn deploy` builds from the tag itself, so there is no separate build step: