From ea197232930b2a751149305a32f04d3c3705af12 Mon Sep 17 00:00:00 2001 From: Mohsen Zainalpour Date: Tue, 29 Sep 2026 18:27:43 -0400 Subject: [PATCH] fix(ci): dispatch publish.yml on the release tag instead of trusting the tag push A tag pushed with RELEASE_TOKEN did not start publish.yml for v0.3.3, leaving the release unpublished. auto-release now dispatches publish.yml on the tag ref when no push-triggered run appears and fails loudly if no Publish run exists after five minutes. publish.yml serializes runs per ref and skips a version that already has a Release or is already on Central, so a double trigger is a clean no-op. Fixes #253 --- .github/workflows/auto-release.yml | 69 +++++++++++++++++++++++++++++- .github/workflows/publish.yml | 50 ++++++++++++++++++++-- CONTRIBUTING.md | 11 ++++- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 332e5e2..a49d63a 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -18,6 +18,14 @@ # workflows). Until that secret exists, this job fails loudly BEFORE merging — bump PRs still open and # run CI, but nothing merges or publishes. Adding RELEASE_TOKEN arms the loop. # +# DISPATCH, NOT JUST THE TAG PUSH (#253): v0.3.3's tag went up with RELEASE_TOKEN and no Publish run +# ever started — whether a pushed tag triggers `on: push: tags` depends on what kind of credential the +# secret holds, which this workflow cannot see. So after pushing the tag the job also dispatches +# `publish.yml` on the tag ref (a workflow_dispatch made with GITHUB_TOKEN always triggers, which is +# why the job needs `actions: write`), then waits until a Publish run for the tag exists and fails +# loudly if none shows up. If the tag push does fire too, publish.yml's per-ref concurrency group and +# its already-released guard turn the second run into a clean no-op. +# # The release version is the root pom's current `-SNAPSHOT` base (e.g. 0.1.3-SNAPSHOT -> 0.1.3), the # Maven-idiomatic "version master is heading toward"; `publish.yml` advances the snapshot afterward. @@ -31,6 +39,7 @@ on: permissions: contents: write pull-requests: write + actions: write # dispatch publish.yml on the tag and watch for its run env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -122,7 +131,7 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "publish=true" >> "$GITHUB_OUTPUT" - - name: Tag the merge commit (triggers publish.yml via RELEASE_TOKEN) + - name: Tag the merge commit if: steps.ver.outputs.publish == 'true' shell: bash env: @@ -139,4 +148,60 @@ jobs: # Central, creates the GitHub Release, and advances master's -SNAPSHOT. The token is passed # in the remote URL via env (GitHub Actions masks secrets in logs). git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${{ github.repository }}.git" "refs/tags/${TAG}" - echo "Pushed ${TAG}; publish.yml will deploy it and bump the snapshot." + echo "Pushed ${TAG}." + + # Do not rely on the tag push alone to start publish.yml (#253). Give the push-triggered run a + # short grace period to appear; if it does not, dispatch publish.yml on the tag ref. Either way, + # require a Publish run for the tag to exist before this job succeeds, so a silent no-publish + # fails here instead of leaving a tagged-but-unpublished release. + - name: Make sure publish.yml runs for the tag + if: steps.ver.outputs.publish == 'true' + shell: bash + env: + TAG: ${{ steps.ver.outputs.tag }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # A Publish run for a tag (push- or dispatch-triggered) reports the tag name as its head + # branch. The tag is brand new, so any such run belongs to this release. + publish_runs() { + gh run list --repo "${REPO}" --workflow publish.yml --branch "${TAG}" --limit 20 \ + --json databaseId,event,status,url \ + --jq '.[] | "\(.databaseId) \(.event) \(.status) \(.url)"' + } + + # wait_for_run : poll until a Publish run for the tag exists; print it and succeed, + # or fail once the deadline passes. + wait_for_run() { + local deadline=$(( SECONDS + $1 )) runs + while :; do + runs="$(publish_runs)" + if [ -n "${runs}" ]; then + printf '%s\n' "${runs}" + return 0 + fi + [ "${SECONDS}" -ge "${deadline}" ] && return 1 + sleep 10 + done + } + + if runs="$(wait_for_run 45)"; then + echo "The tag push started publish.yml for ${TAG}; no dispatch needed:" + printf '%s\n' "${runs}" + exit 0 + fi + + echo "No Publish run for ${TAG} after the tag push; dispatching publish.yml on the tag ref." + gh workflow run publish.yml --repo "${REPO}" --ref "${TAG}" + + if runs="$(wait_for_run 300)"; then + echo "publish.yml is running for ${TAG}; it deploys the release and bumps the snapshot:" + printf '%s\n' "${runs}" + exit 0 + fi + + echo "::error::No Publish run for ${TAG} appeared within 5 minutes of the tag push and an explicit" + echo "::error::dispatch. ${TAG} is tagged but NOT published. Publish it by hand:" + echo "::error:: gh workflow run publish.yml --repo ${REPO} --ref ${TAG}" + exit 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0e8353d..65376e7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,8 +8,20 @@ on: push: branches: [master] tags: ['v*'] + # A release can also arrive as a dispatch on the tag ref: auto-release.yml runs + # `gh workflow run publish.yml --ref vX.Y.Z` after pushing the tag, so the release does not depend + # on the tag push triggering this workflow (#253). On such a dispatch github.ref is refs/tags/vX.Y.Z + # and GITHUB_REF_NAME is vX.Y.Z, exactly as on a tag push, so every tag-gated step below behaves + # the same either way. workflow_dispatch: +# The tag push AND auto-release's dispatch can both start a run for the same tag. Serialize runs per +# ref (never cancel one mid-deploy) so the second starts only after the first has finished, and let +# the "already released" guard below turn it into a clean no-op. +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + jobs: publish: name: Publish to Maven Central @@ -30,19 +42,49 @@ jobs: CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail # Require the full credential set; a partial config would red-fail the signing/deploy # steps, so skip cleanly instead and tell the maintainer what is missing. - if [ -n "$CENTRAL_USERNAME" ] && [ -n "$CENTRAL_PASSWORD" ] \ - && [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_PASSPHRASE" ]; then - echo "deploy=true" >> "$GITHUB_OUTPUT" - else + if ! { [ -n "$CENTRAL_USERNAME" ] && [ -n "$CENTRAL_PASSWORD" ] \ + && [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_PASSPHRASE" ]; }; then echo "Publishing credentials incomplete — skipping deploy." echo "Configure all of MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD, GPG_PRIVATE_KEY" echo "and MAVEN_GPG_PASSPHRASE repository secrets to enable Central deployment." echo "deploy=false" >> "$GITHUB_OUTPUT" + exit 0 fi + # Double-publish guard: on a release tag, no-op if this version already shipped (a second run + # for the same tag — tag push + dispatch, or a re-run). Central refuses to overwrite a release, + # so redeploying would only fail. Two independent signals, either one sufficient: + # - the GitHub Release object, which a successful run creates as its last publish step; + # - the Central Portal's own "is this published" answer, for a run that deployed but died + # before creating the Release. Only a definitive `"published":true` counts; any other + # answer (error, false) proceeds to deploy, where a real duplicate fails loudly. + case "$GITHUB_REF" in + refs/tags/v*) + VERSION="${GITHUB_REF_NAME#v}" + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::notice::Release $GITHUB_REF_NAME already exists — $VERSION was published by an earlier run; skipping." + echo "deploy=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + AUTH="$(printf '%s:%s' "$CENTRAL_USERNAME" "$CENTRAL_PASSWORD" | base64 -w0)" + PUBLISHED="$(curl -sS --max-time 30 -H "Authorization: Bearer $AUTH" \ + "https://central.sonatype.com/api/v1/publisher/published?namespace=io.github.achird-labs&name=rift-java-core&version=$VERSION" \ + || echo '{}')" + echo "Central Portal: rift-java-core $VERSION -> $PUBLISHED" + if printf '%s' "$PUBLISHED" | grep -Eq '"published"[[:space:]]*:[[:space:]]*true'; then + echo "::notice::rift-java-core $VERSION is already on Maven Central; skipping the redeploy." + echo "deploy=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + ;; + esac + echo "deploy=true" >> "$GITHUB_OUTPUT" + # The reactor runs on JDK 21 (LTS): it builds the zero-dep modules and the JDK 21 preview # embedded variant (rift-java-embedded-jdk21) natively. JDK 22 is installed alongside so the # stable-FFM rift-java-embedded jar can be compiled via a toolchain in the same reactor and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96ccdc6..73ebbac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,7 +142,11 @@ Artifacts publish to Maven Central under the `io.github.achird-labs` namespace v - **Snapshots** deploy automatically on every push to `master`, at whatever `-SNAPSHOT` version the root pom currently carries. -- **Releases are cut by pushing a `vX.Y.Z` tag** — that is the only trigger. The `Publish` workflow +- **Releases are cut by a `vX.Y.Z` tag** — pushing it triggers `Publish`, and so does dispatching + `Publish` on the tag ref (`gh workflow run publish.yml --ref vX.Y.Z`), which is how to publish a + tag whose push did not start a run. Runs are serialized per tag, and a run for a version that + already has a GitHub Release (or is already on Central) skips the deploy, so a duplicate trigger is + a harmless no-op. The `Publish` workflow stamps every module with the version from the tag, deploys, *then* creates the GitHub Release object and pushes a follow-up commit advancing `master` to the next `-SNAPSHOT` (which also syncs the README's install snippets to the released version). @@ -159,7 +163,10 @@ separate `RELEASE_TOKEN` secret. `Engine Bump` (weekly, plus `workflow_dispatch`) polls `achird-labs/rift` and opens a `chore/engine-` PR through the reusable `dep-bump.yml`; when CI on that PR is green, -`auto-release.yml` merges it and pushes the release tag that `Publish` acts on. The loop needs **two** +`auto-release.yml` merges it and pushes the release tag that `Publish` acts on. It does not trust the +tag push alone to start `Publish`: if no run for the tag appears within a short grace period it +dispatches `publish.yml` on the tag ref itself, and it fails if no `Publish` run for the tag exists +five minutes later — so a tagged-but-unpublished release is a red run, not a silent gap (#253). The loop needs **two** repository secrets, and it stalls in a different place if either is missing: | secret | used for | if absent |