From ae38f7aed8d7c0909000181127d2d77bc021e66b Mon Sep 17 00:00:00 2001 From: Shubham Jain Date: Fri, 31 Jul 2026 17:53:56 +0530 Subject: [PATCH 1/3] ci: consolidate release onto GitHub Actions and drop Woodpecker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo ran two CI systems in parallel. .woodpecker/build.yml was a verbatim duplicate of .github/workflows/java-agent.yml — same JDK 8/17/21 matrix, same `mvn -B -DskipTests clean verify` plus smoke test — so every PR and every push to main built the project twice. The release side was split rather than duplicated: Woodpecker published to Maven Central while GitHub Actions published the GitHub Release, both triggered independently off the same tag. That split is what let v2.0.6 end up with a green GitHub Release while the Woodpecker Central publish had failed, and it meant the jar attached to the GitHub Release was a separate, unsigned build of the one on Central. This is a public repo, so PR checks belong where contributors can see them. Woodpecker is removed entirely and its Central publishing is folded into the release workflow, which now does the whole release in one job: build + smoke test (pre-flight gate, unsigned) -> sign and deploy to Maven Central -> wait for the artifact on repo1 -> create the GitHub Release from those same signed artifacts Central publishing is irreversible and is the release that matters, so it runs first and gates everything after it: a failed Central publish now leaves no GitHub Release behind. The release assets are the exact files deployed to Central, with their .asc signatures attached so they can be verified against the release key. The sources and javadoc jars are now copied unconditionally instead of behind an `if -f` guard, so a release build that fails to produce them fails the job rather than silently publishing an incomplete release. Requires these repository secrets, previously held by Woodpecker: MAVEN_GPG_PRIVATE_KEY, MAVEN_GPG_PASSPHRASE, CENTRAL_USERNAME, CENTRAL_PASSWORD. Signed-off-by: Shubham Jain --- .github/workflows/release.yml | 103 ++++++++++++++++++++++++++-------- .woodpecker/build.yml | 33 ----------- .woodpecker/release.yml | 74 ------------------------ 3 files changed, 81 insertions(+), 129 deletions(-) delete mode 100644 .woodpecker/build.yml delete mode 100644 .woodpecker/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fba8815..8bc4e1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,15 @@ name: Release -# Triggers on a v*.*.* tag push and creates a GitHub Release with the -# Keploy Java agent jar attached as a downloadable asset. Maven Central -# publishing happens in parallel via the Woodpecker pipeline at -# .woodpecker/release.yml — this workflow does not touch Central. +# Triggers on a v*.*.* tag push and performs the complete release: +# builds and smoke tests the agent, signs and publishes it to Maven +# Central, waits for it to land on repo1, and only then creates the +# GitHub Release with the exact same signed artifacts attached. +# +# Ordering is deliberate: Central publishing is irreversible and is the +# release that matters, so it happens first and gates everything after +# it. A failed Central publish therefore leaves no GitHub Release behind, +# which is what previously allowed a version to exist on GitHub but not +# on Central (v2.0.6). on: push: @@ -16,6 +22,14 @@ permissions: jobs: release: runs-on: ubuntu-latest + # Central's publishing plugin blocks until the deployment reaches the + # PUBLISHED state (waitMaxTime=7200 in the root pom), and the repo1 + # poll below adds up to 30 more minutes. + timeout-minutes: 150 + env: + # Public key id of the Keploy release signing key. Selects the right + # secret key when more than one is present in the keyring. + GPG_KEY_ID: 8541784E4EC36FB8 steps: - name: Checkout uses: actions/checkout@v4 @@ -32,6 +46,14 @@ jobs: distribution: temurin java-version: '17' cache: maven + # Writes ~/.m2/settings.xml with a `central` server entry that + # resolves from the env vars named below, and imports the + # release signing key (removed again when the job ends). + server-id: central + server-username: CENTRAL_USERNAME + server-password: CENTRAL_PASSWORD + gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE - name: Set release version in poms run: >- @@ -40,30 +62,67 @@ jobs: -DprocessAllModules=true -DgenerateBackupPoms=false - - name: Build agent jar with sources and javadoc - # `release` profile attaches the source jar; the inner pom always - # attaches the javadoc jar. -Dgpg.skip=true keeps the signing step - # in the release profile inert (signing happens on Woodpecker - # where the GPG key is wired up). - run: mvn -B -ntp -P release -DskipTests -Dgpg.skip=true clean verify + - name: Build and smoke test the agent + # Pre-flight gate on a plain (unsigned) build: publishing to Central + # cannot be undone, so the agent is proven to actually attach and + # run before anything leaves this job. + run: | + mvn -B -ntp -DskipTests clean verify + ./scripts/smoke-javaagent.sh - - name: Smoke test java agent - run: ./scripts/smoke-javaagent.sh + - name: Publish to Maven Central + # Single clean build so the artifacts staged for the GitHub Release + # below are byte-identical to the ones published to Central. The + # `release` profile attaches sources + javadoc, signs everything + # with maven-gpg-plugin, and deploys via central-publishing-maven-plugin. + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: >- + mvn -B -ntp -P release -DskipTests clean deploy + -Dgpg.keyname="$GPG_KEY_ID" + -Dgpg.passphrase="$MAVEN_GPG_PASSPHRASE" + -Dgpg.useagent=false + + - name: Wait for artifact on Maven Central + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + artifact_url="https://repo.maven.apache.org/maven2/io/keploy/keploy-sdk/${VERSION}/keploy-sdk-${VERSION}.jar" + echo "Waiting for $artifact_url" + for attempt in $(seq 1 60); do + if curl -fsI "$artifact_url" >/dev/null; then + echo "Released artifact is available on Maven Central" + exit 0 + fi + echo "Artifact not available yet, retrying in 30s ($attempt/60)" + sleep 30 + done + echo "Released artifact is not available on Maven Central after 30 minutes" + exit 1 - name: Stage release assets + env: + VERSION: ${{ steps.ver.outputs.version }} run: | set -euxo pipefail - version="${{ steps.ver.outputs.version }}" + # Non-matching globs expand to nothing rather than to the literal + # pattern, so the signature copy below cannot create junk files. + shopt -s nullglob mkdir -p release-assets - cp "keploy-sdk/target/keploy-sdk-${version}.jar" "release-assets/keploy-sdk-${version}.jar" - cp "keploy-sdk/target/keploy-sdk-${version}.jar" "release-assets/keploy-sdk.jar" - if [[ -f "keploy-sdk/target/keploy-sdk-${version}-sources.jar" ]]; then - cp "keploy-sdk/target/keploy-sdk-${version}-sources.jar" "release-assets/" - fi - if [[ -f "keploy-sdk/target/keploy-sdk-${version}-javadoc.jar" ]]; then - cp "keploy-sdk/target/keploy-sdk-${version}-javadoc.jar" "release-assets/" - fi - cp "keploy-sdk/pom.xml" "release-assets/keploy-sdk-${version}.pom" + cp "keploy-sdk/target/keploy-sdk-${VERSION}.jar" "release-assets/keploy-sdk-${VERSION}.jar" + # Unversioned copy so docs and scripts can link a stable filename. + cp "keploy-sdk/target/keploy-sdk-${VERSION}.jar" "release-assets/keploy-sdk.jar" + cp "keploy-sdk/target/keploy-sdk-${VERSION}-sources.jar" "release-assets/" + cp "keploy-sdk/target/keploy-sdk-${VERSION}-javadoc.jar" "release-assets/" + cp "keploy-sdk/pom.xml" "release-assets/keploy-sdk-${VERSION}.pom" + # Detached GPG signatures, so the release assets can be verified + # against the same key that signed the Central artifacts. + for f in keploy-sdk/target/keploy-sdk-"${VERSION}"*.jar.asc; do + cp "$f" "release-assets/" + done ls -la release-assets/ - name: Publish GitHub Release diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml deleted file mode 100644 index 59356d5..0000000 --- a/.woodpecker/build.yml +++ /dev/null @@ -1,33 +0,0 @@ -when: - - event: pull_request - - event: push - branch: main - -labels: - platform: linux/amd64 - -clone: - git: - image: woodpeckerci/plugin-git - settings: - lfs: false - depth: 1 - -steps: - build-jdk-8: - image: maven:3.9-eclipse-temurin-8 - commands: - - mvn -B -DskipTests clean verify - - ./scripts/smoke-javaagent.sh - - build-jdk-17: - image: maven:3.9-eclipse-temurin-17 - commands: - - mvn -B -DskipTests clean verify - - ./scripts/smoke-javaagent.sh - - build-jdk-21: - image: maven:3.9-eclipse-temurin-21 - commands: - - mvn -B -DskipTests clean verify - - ./scripts/smoke-javaagent.sh diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml deleted file mode 100644 index 2e6479e..0000000 --- a/.woodpecker/release.yml +++ /dev/null @@ -1,74 +0,0 @@ -when: - - event: tag - ref: refs/tags/v*.*.* - -labels: - platform: linux/amd64 - -clone: - git: - image: woodpeckerci/plugin-git - settings: - lfs: false - depth: 0 - -steps: - release: - image: maven:3.9-eclipse-temurin-17 - environment: - MAVEN_GPG_PRIVATE_KEY: - from_secret: maven_gpg_private_key - MAVEN_GPG_PASSPHRASE: - from_secret: maven_gpg_passphrase - GPG_KEY_ID: 8541784E4EC36FB8 - CENTRAL_USERNAME: - from_secret: central_username - CENTRAL_PASSWORD: - from_secret: "central password" - commands: - - apt-get update -qq && apt-get install -y -qq curl gnupg - - | - key_file="$(mktemp)" - printf '%s' "$MAVEN_GPG_PRIVATE_KEY" | sed 's/\\n/\n/g' > "$key_file" - if ! gpg --batch --import "$key_file"; then - printf '%s' "$MAVEN_GPG_PRIVATE_KEY" | sed 's/\\n//g' | base64 -d > "$key_file" - gpg --batch --import "$key_file" - fi - rm -f "$key_file" - gpg --batch --list-secret-keys --keyid-format LONG "$GPG_KEY_ID" - - mkdir -p ~/.m2 - - | - cat > ~/.m2/settings.xml < - - - central - $CENTRAL_USERNAME - $CENTRAL_PASSWORD - - - - XML - - VERSION="$(printf '%s' "$CI_COMMIT_TAG" | sed 's/^v//')" - - echo "Releasing version $VERSION" - - mvn -B versions:set -DnewVersion="$VERSION" -DprocessAllModules=true -DgenerateBackupPoms=false - - mvn -B -P release clean deploy -DskipTests -Dgpg.keyname="$GPG_KEY_ID" -Dgpg.passphrase="$MAVEN_GPG_PASSPHRASE" -Dgpg.useagent=false - - | - # Use $VERSION without braces — Woodpecker's YAML-level templater - # rewrites ${VAR} at config-load time and replaces unknown names - # with the empty string, but $VAR (no braces) passes through to - # the shell intact. Using ${VERSION} here previously produced - # https://repo.maven.apache.org/maven2/io/keploy/keploy-sdk//keploy-sdk-.jar - # which 404s forever and failed the wait step on every release. - artifact_url="https://repo.maven.apache.org/maven2/io/keploy/keploy-sdk/$VERSION/keploy-sdk-$VERSION.jar" - echo "Waiting for $artifact_url" - for attempt in $(seq 1 60); do - if curl -fsI "$artifact_url" >/dev/null; then - echo "Released artifact is available on Maven Central" - exit 0 - fi - echo "Artifact not available yet, retrying in 30s ($attempt/60)" - sleep 30 - done - echo "Released artifact is not available on Maven Central after 30 minutes" - exit 1 From 0eeef906325177d7bbdff6eb9d1b2e31a0601f22 Mon Sep 17 00:00:00 2001 From: Shubham Jain Date: Fri, 31 Jul 2026 18:17:54 +0530 Subject: [PATCH 2/3] ci: harden the consolidated release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the Woodpecker migration. The most important one is that the previous version of this workflow got its own rationale wrong. v2.0.6 is, and always was, on Maven Central — jar, sources, pom and signatures all present. What actually failed on that release was the Woodpecker *wait* step, via the ${VERSION} templating bug fixed in c1b96c1. The deploy had already succeeded. So the real incident was cosmetic, and the single-job design made that exact shape unrecoverable: a slow repo1 sync would fail the job after an irreversible publish, and re-running it could only re-attempt a deploy that Central would now reject, permanently stranding the GitHub Release. Split into two jobs so publishing and announcing fail independently: publish build -> sign -> smoke test -> stage assets -> deploy github-release wait for repo1 -> create the GitHub Release Everything that can fail cheaply now runs before the point of no return, and the deploy is the last step of its job. If Central syncs slowly, only github-release fails and it can be re-run on its own; the staged assets are carried between jobs as a workflow artifact so a re-run publishes the exact bits that were validated. workflow_dispatch is added as a manual recovery path, and a concurrency group prevents two releases of the same ref from overlapping (cancel-in-progress is deliberately false). The release jar is now the jar that was smoke tested. The deploy reuses target/ without cleaning, and maven-shade-plugin is idempotent here, so the published artifact is byte-identical to the one the smoke test ran against — verified locally by re-running the lifecycle against a signed build and comparing checksums. Also: - Validate the version against ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ before the signing key is imported. Git accepts backticks and semicolons in ref names and `v*.*.*` matches them, so an unquoted tag interpolated into the Maven arguments was a shell injection reachable by anyone with push access, in a job holding the Central signing key. - Pin versions-maven-plugin to 2.21.0. Unqualified `versions:set` resolved the latest plugin from Central at run time. - Require signatures instead of tolerating their absence. The nullglob guard meant a signature-less build would publish a green, unverifiable GitHub Release; an unmatched glob now fails the job. Also stages the pom signatures and the parent pom, which the previous *.jar.asc glob skipped, so the assets are usable and verifiable offline. - Drop -Dgpg.passphrase from argv. actions/setup-java's gpg-passphrase already wires the gpg.passphrase server entry that maven-gpg-plugin reads; passing it again only exposed it in the process table. Verified that signing succeeds via the settings.xml route alone. - Add --max-time to the repo1 poll so a stalled connection cannot blow past the intended 30 minute bound, and promote a release left as a draft by an interrupted run. - Raise the timeout budget and split it across the two jobs. The pom's waitMaxTime=7200 is 120 minutes on its own, so the old single 150 minute cap could not cover it plus the build and the 30 minute poll. - Restore CI for pull requests that do not target main. Woodpecker ran the matrix on every pull request regardless of target; the branch filter in java-agent.yml silently dropped coverage for this repo's long-lived integration branches. Signed-off-by: Shubham Jain --- .github/workflows/java-agent.yml | 4 +- .github/workflows/release.yml | 226 ++++++++++++++++++++++--------- 2 files changed, 162 insertions(+), 68 deletions(-) diff --git a/.github/workflows/java-agent.yml b/.github/workflows/java-agent.yml index 4ecb7da..5c79de5 100644 --- a/.github/workflows/java-agent.yml +++ b/.github/workflows/java-agent.yml @@ -1,8 +1,10 @@ name: Java Agent on: + # Deliberately unfiltered: the Woodpecker pipeline this replaced ran the + # matrix on every pull request regardless of target, and this repo has + # several long-lived integration branches that would otherwise get no CI. pull_request: - branches: [main] push: branches: [main] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bc4e1e..f16c8e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,31 +1,52 @@ name: Release -# Triggers on a v*.*.* tag push and performs the complete release: -# builds and smoke tests the agent, signs and publishes it to Maven -# Central, waits for it to land on repo1, and only then creates the -# GitHub Release with the exact same signed artifacts attached. +# Triggers on a v*.*.* tag push and performs the complete release: builds +# and signs the agent, smoke tests the exact jar that will ship, publishes +# it to Maven Central, and creates the GitHub Release from those same +# signed artifacts. # -# Ordering is deliberate: Central publishing is irreversible and is the -# release that matters, so it happens first and gates everything after -# it. A failed Central publish therefore leaves no GitHub Release behind, -# which is what previously allowed a version to exist on GitHub but not -# on Central (v2.0.6). +# Split into two jobs on purpose. Publishing to Central is irreversible +# and non-idempotent — the Portal rejects a re-upload of a version that +# already exists — so everything that can fail cheaply (build, smoke test, +# asset staging) runs *before* it, and everything after it lives in a +# separate job that can be re-run on its own without touching Central. +# +# This matters because the one release incident this repo has actually +# had, on v2.0.6, was exactly that shape: the Woodpecker deploy succeeded +# and the pipeline then failed in the repo1 wait step (a ${VERSION} +# templating bug, fixed in c1b96c1). v2.0.6 is on Central and is fine. Had +# the wait step been able to strand the release, that cosmetic failure +# would have been unrecoverable. +# +# workflow_dispatch is available as a manual recovery path; run it from +# the tag ref, not a branch. on: push: tags: - 'v*.*.*' + workflow_dispatch: permissions: contents: write +# Never let two releases of the same ref overlap. cancel-in-progress is +# deliberately false: cancelling mid-deploy is the one thing that could +# strand a half-published version. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: - release: + publish: + name: Build, sign and publish to Maven Central runs-on: ubuntu-latest - # Central's publishing plugin blocks until the deployment reaches the - # PUBLISHED state (waitMaxTime=7200 in the root pom), and the repo1 - # poll below adds up to 30 more minutes. + # The pom's central-publishing-maven-plugin blocks until Central + # reports the deployment PUBLISHED (waitMaxTime=7200, i.e. 120 + # minutes), plus the build and smoke test ahead of it. timeout-minutes: 150 + outputs: + version: ${{ steps.ver.outputs.version }} env: # Public key id of the Keploy release signing key. Selects the right # secret key when more than one is present in the keyring. @@ -36,9 +57,22 @@ jobs: with: fetch-depth: 0 - - name: Resolve release version + - name: Resolve and validate release version id: ver - run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + # Runs before the signing key is imported, and rejects anything + # that is not a plain vMAJOR.MINOR.PATCH[-suffix] tag. The tag name + # is attacker-influenced (git accepts backticks and semicolons in + # ref names, and `v*.*.*` happily matches them), and it is + # interpolated into Maven arguments below. + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then + echo "Refusing to release from ref '${GITHUB_REF_NAME}'." >&2 + echo "Expected a tag of the form vMAJOR.MINOR.PATCH[-suffix]." >&2 + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -47,8 +81,11 @@ jobs: java-version: '17' cache: maven # Writes ~/.m2/settings.xml with a `central` server entry that - # resolves from the env vars named below, and imports the - # release signing key (removed again when the job ends). + # resolves from the env vars named below, and imports the release + # signing key (removed again when the job ends). The key must be + # stored as a plain ASCII-armored block: unlike the Woodpecker + # script this replaces, setup-java has no base64 / escaped-newline + # fallback. server-id: central server-username: CENTRAL_USERNAME server-password: CENTRAL_PASSWORD @@ -56,44 +93,116 @@ jobs: gpg-passphrase: MAVEN_GPG_PASSPHRASE - name: Set release version in poms + # Pinned: an unqualified `versions:set` resolves the latest plugin + # from Central at run time, which would execute unreviewed code in + # the job that holds the signing key. + env: + VERSION: ${{ steps.ver.outputs.version }} run: >- - mvn -B -ntp versions:set - -DnewVersion=${{ steps.ver.outputs.version }} + mvn -B -ntp org.codehaus.mojo:versions-maven-plugin:2.21.0:set + -DnewVersion="$VERSION" -DprocessAllModules=true -DgenerateBackupPoms=false - - name: Build and smoke test the agent - # Pre-flight gate on a plain (unsigned) build: publishing to Central - # cannot be undone, so the agent is proven to actually attach and - # run before anything leaves this job. + - name: Build and sign + # The `release` profile attaches sources + javadoc and signs every + # artifact with maven-gpg-plugin. + env: + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: >- + mvn -B -ntp -P release -DskipTests clean verify + -Dgpg.keyname="$GPG_KEY_ID" + + - name: Smoke test the release jar + # Runs against the artifact built above, which — because the deploy + # step below reuses this same target/ without cleaning — is the + # exact jar that gets published. maven-shade-plugin is idempotent + # here (it shades from original-*.jar), so re-running the lifecycle + # for `deploy` reproduces this jar byte for byte. + run: ./scripts/smoke-javaagent.sh + + - name: Stage release assets + # Deliberately ahead of the Central deploy: this is fiddly, + # failure-prone file shuffling, and it must not be able to fail + # after an irreversible publish. + env: + VERSION: ${{ steps.ver.outputs.version }} run: | - mvn -B -ntp -DskipTests clean verify - ./scripts/smoke-javaagent.sh + set -euo pipefail + mkdir -p release-assets + cp "keploy-sdk/target/keploy-sdk-${VERSION}.jar" release-assets/ + cp "keploy-sdk/target/keploy-sdk-${VERSION}-sources.jar" release-assets/ + cp "keploy-sdk/target/keploy-sdk-${VERSION}-javadoc.jar" release-assets/ + # Unversioned copy so docs and scripts can link a stable filename, + # with a matching signature so it is verifiable under that name too. + cp "keploy-sdk/target/keploy-sdk-${VERSION}.jar" "release-assets/keploy-sdk.jar" + cp "keploy-sdk/target/keploy-sdk-${VERSION}.jar.asc" "release-assets/keploy-sdk.jar.asc" + # The deployed poms are the source poms verbatim + # (createDependencyReducedPom=false), and the module pom declares + # the parent, so both are needed to consume these assets offline. + cp "keploy-sdk/pom.xml" "release-assets/keploy-sdk-${VERSION}.pom" + cp "pom.xml" "release-assets/java-sdk-${VERSION}.pom" + # Signatures. No nullglob: an unmatched glob must fail the job + # rather than quietly publish an unverifiable release. + cp keploy-sdk/target/*.asc release-assets/ + cp target/*.asc release-assets/ + # Belt and braces — assert every artifact shipped is signed. + missing=0 + for f in release-assets/*.jar release-assets/*.pom; do + if [[ ! -f "${f}.asc" ]]; then + echo "Unsigned release asset: ${f}" >&2 + missing=1 + fi + done + [[ "$missing" -eq 0 ]] || exit 1 + ls -la release-assets/ + + - name: Upload release assets + # Persisted so the github-release job below — and any later re-run + # of it — works from the exact artifacts that were validated here. + uses: actions/upload-artifact@v4 + with: + name: release-assets + path: release-assets/ + retention-days: 7 + if-no-files-found: error - name: Publish to Maven Central - # Single clean build so the artifacts staged for the GitHub Release - # below are byte-identical to the ones published to Central. The - # `release` profile attaches sources + javadoc, signs everything - # with maven-gpg-plugin, and deploys via central-publishing-maven-plugin. + # Last step in the job, and the point of no return. No `clean`: this + # reuses and republishes the artifacts smoke tested above. env: CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} run: >- - mvn -B -ntp -P release -DskipTests clean deploy + mvn -B -ntp -P release -DskipTests deploy -Dgpg.keyname="$GPG_KEY_ID" - -Dgpg.passphrase="$MAVEN_GPG_PASSPHRASE" - -Dgpg.useagent=false + + github-release: + name: Publish GitHub Release + needs: publish + runs-on: ubuntu-latest + # Bounded by the repo1 poll below. Kept in its own job so that a slow + # Central sync fails here, re-runnably, instead of stranding a release. + timeout-minutes: 45 + steps: + - name: Download release assets + uses: actions/download-artifact@v4 + with: + name: release-assets + path: release-assets - name: Wait for artifact on Maven Central env: - VERSION: ${{ steps.ver.outputs.version }} + VERSION: ${{ needs.publish.outputs.version }} run: | set -euo pipefail artifact_url="https://repo.maven.apache.org/maven2/io/keploy/keploy-sdk/${VERSION}/keploy-sdk-${VERSION}.jar" echo "Waiting for $artifact_url" for attempt in $(seq 1 60); do - if curl -fsI "$artifact_url" >/dev/null; then + # --max-time so a stalled connection cannot blow past the + # intended 30-minute bound. + if curl -fsI --max-time 30 "$artifact_url" >/dev/null; then echo "Released artifact is available on Maven Central" exit 0 fi @@ -101,55 +210,38 @@ jobs: sleep 30 done echo "Released artifact is not available on Maven Central after 30 minutes" + echo "The Central publish itself succeeded; re-run this job once it syncs." exit 1 - - name: Stage release assets - env: - VERSION: ${{ steps.ver.outputs.version }} - run: | - set -euxo pipefail - # Non-matching globs expand to nothing rather than to the literal - # pattern, so the signature copy below cannot create junk files. - shopt -s nullglob - mkdir -p release-assets - cp "keploy-sdk/target/keploy-sdk-${VERSION}.jar" "release-assets/keploy-sdk-${VERSION}.jar" - # Unversioned copy so docs and scripts can link a stable filename. - cp "keploy-sdk/target/keploy-sdk-${VERSION}.jar" "release-assets/keploy-sdk.jar" - cp "keploy-sdk/target/keploy-sdk-${VERSION}-sources.jar" "release-assets/" - cp "keploy-sdk/target/keploy-sdk-${VERSION}-javadoc.jar" "release-assets/" - cp "keploy-sdk/pom.xml" "release-assets/keploy-sdk-${VERSION}.pom" - # Detached GPG signatures, so the release assets can be verified - # against the same key that signed the Central artifacts. - for f in keploy-sdk/target/keploy-sdk-"${VERSION}"*.jar.asc; do - cp "$f" "release-assets/" - done - ls -la release-assets/ - - name: Publish GitHub Release env: GITHUB_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} TAG: ${{ github.ref_name }} + SHA: ${{ github.sha }} run: | - set -euxo pipefail + set -euo pipefail # Pre-release flag: anything with a hyphen suffix (e.g. v2.0.6-rc1) prerelease_flag=() if [[ "$TAG" == *-* ]]; then prerelease_flag=(--prerelease) fi - # Idempotent publish: if a previous run already created the - # release record, --clobber overwrites partial assets so a - # rerun ends in the same state either way. - if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + # Idempotent re-run: --clobber overwrites partial assets so the + # job ends in the same state either way. A release left as a + # draft by an interrupted run is promoted, otherwise it would + # stay invisible forever. echo "Release $TAG already exists — uploading assets with --clobber" - gh release upload "$TAG" \ - --repo "${{ github.repository }}" \ - --clobber \ - release-assets/* + gh release upload "$TAG" --repo "$REPO" --clobber release-assets/* + if [[ "$(gh release view "$TAG" --repo "$REPO" --json isDraft -q .isDraft)" == "true" ]]; then + echo "Promoting draft release $TAG" + gh release edit "$TAG" --repo "$REPO" --draft=false + fi else gh release create "$TAG" \ - --repo "${{ github.repository }}" \ + --repo "$REPO" \ --title "Java SDK $TAG" \ - --target "${{ github.sha }}" \ + --target "$SHA" \ --generate-notes \ "${prerelease_flag[@]}" \ release-assets/* From ab052980a93c0ec445c496fae2361946f3f1d9b4 Mon Sep 17 00:00:00 2001 From: Shubham Jain Date: Fri, 31 Jul 2026 18:38:39 +0530 Subject: [PATCH 3/3] ci: make the release deploy idempotent so recovery actually works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass on the release workflow. The happy path held up; the recovery path did not. The two-job split let a failed github-release job be re-run without re-deploying, but nothing made the deploy itself idempotent. Any whole-run retrigger — a re-pushed tag, "Re-run all jobs", or the workflow_dispatch that the header comment advertises as the manual recovery path — re-ran `mvn deploy` against a version Central had already accepted, was rejected, and skipped github-release. The documented recovery path could therefore never produce the release it existed to rescue. Worse, "Re-run all jobs" deletes the run's artifacts first, so that one wrong click also destroyed the validated, signed assets the safe path depended on. The deploy is now guarded by a check against Central and skipped when the version is already published, so every retrigger converges on the same end state instead of dead-ending. Also: - Add overwrite: true to the artifact upload. v4 names are immutable within a run and partial re-runs do not clear them, so re-running a publish job that failed at the deploy would have 409'd at the upload, before reaching the step that failed. - Bound the repo1 poll by elapsed time rather than iteration count. With --max-time 30 on each request, 60 iterations of request+sleep could run for a full hour against a 45 minute job timeout, so a slow sync would be killed by the timeout and the actionable "re-run this job" message would never print. - Fix the version guard. It rejected legal pre-release tags containing a hyphen (v2.1.0-alpha-1) and accepted both SNAPSHOT versions and leading zeros, where v02.1.0 would have irreversibly published a coordinate distinct from v2.1.0. Uses a portable case-insensitive SNAPSHOT match rather than ${var^^}, which needs bash 4 and so could not be exercised locally. - Correct the byte-for-byte claim on the smoke test step. The agent jar and sources jar are identical across the two lifecycle passes because jar:jar and source:jar-no-fork skip as up-to-date, not because shade reads original-*.jar; the javadoc jar is regenerated and differs by its embedded timestamp. - Add a concurrency group to java-agent.yml so superseded PR runs are cancelled, now that its trigger is unfiltered across a repo with many long-lived branches. Signed-off-by: Shubham Jain --- .github/workflows/java-agent.yml | 7 ++++ .github/workflows/release.yml | 64 ++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/.github/workflows/java-agent.yml b/.github/workflows/java-agent.yml index 5c79de5..057f1cb 100644 --- a/.github/workflows/java-agent.yml +++ b/.github/workflows/java-agent.yml @@ -8,6 +8,13 @@ on: push: branches: [main] +# Superseded runs are pointless work: a push to a PR branch makes the +# previous run's result irrelevant. Matters more now that the trigger above +# is unfiltered and this repo carries a lot of long-lived branches. +concurrency: + group: java-agent-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: verify: name: JDK ${{ matrix.java-version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f16c8e9..082930a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,9 +67,19 @@ jobs: run: | set -euo pipefail version="${GITHUB_REF_NAME#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then + # Hyphens are allowed inside the pre-release identifier (v2.1.0-alpha-1 + # is legal and `v*.*.*` matches it). Leading zeros are not: 02.1.0 and + # 2.1.0 are distinct, permanent coordinates on Central. + if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]]; then echo "Refusing to release from ref '${GITHUB_REF_NAME}'." >&2 - echo "Expected a tag of the form vMAJOR.MINOR.PATCH[-suffix]." >&2 + echo "Expected a tag of the form vMAJOR.MINOR.PATCH[-prerelease]," >&2 + echo "with no leading zeros in the numeric components." >&2 + exit 1 + fi + # Central only accepts release versions, and a SNAPSHOT tag would + # otherwise burn a full build and smoke test before being rejected. + if [[ "$version" == *[Ss][Nn][Aa][Pp][Ss][Hh][Oo][Tt]* ]]; then + echo "Refusing to release a SNAPSHOT version: ${version}" >&2 exit 1 fi echo "version=${version}" >> "$GITHUB_OUTPUT" @@ -115,10 +125,12 @@ jobs: - name: Smoke test the release jar # Runs against the artifact built above, which — because the deploy - # step below reuses this same target/ without cleaning — is the - # exact jar that gets published. maven-shade-plugin is idempotent - # here (it shades from original-*.jar), so re-running the lifecycle - # for `deploy` reproduces this jar byte for byte. + # step below reuses this same target/ without cleaning — is the exact + # jar that gets published: on the second lifecycle pass `jar:jar` and + # `source:jar-no-fork` skip as up-to-date, so the agent jar and the + # sources jar are byte-identical to the ones tested here. (The javadoc + # jar is regenerated and differs by its embedded generation timestamp; + # it is content-equivalent and carries its own signature.) run: ./scripts/smoke-javaagent.sh - name: Stage release assets @@ -166,10 +178,35 @@ jobs: path: release-assets/ retention-days: 7 if-no-files-found: error + # v4 artifact names are immutable within a run and a partial re-run + # does not clear them, so without this a re-run of a failed publish + # job would 409 here — before it ever reached the deploy. + overwrite: true + + - name: Check whether this version is already published + id: central + # Makes the deploy idempotent, which is what makes recovery work at + # all. Central rejects a re-upload of an existing version, so without + # this any whole-run retrigger — a re-pushed tag, "Re-run all jobs", + # or the workflow_dispatch below — would dead-end here and skip the + # github-release job forever, permanently stranding the release it + # was supposed to rescue. + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + url="https://repo.maven.apache.org/maven2/io/keploy/keploy-sdk/${VERSION}/keploy-sdk-${VERSION}.jar" + if curl -fsI --max-time 30 "$url" >/dev/null; then + echo "already=true" >> "$GITHUB_OUTPUT" + echo "${VERSION} is already on Maven Central — skipping the deploy." + else + echo "already=false" >> "$GITHUB_OUTPUT" + fi - name: Publish to Maven Central # Last step in the job, and the point of no return. No `clean`: this # reuses and republishes the artifacts smoke tested above. + if: steps.central.outputs.already != 'true' env: CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} @@ -199,18 +236,23 @@ jobs: set -euo pipefail artifact_url="https://repo.maven.apache.org/maven2/io/keploy/keploy-sdk/${VERSION}/keploy-sdk-${VERSION}.jar" echo "Waiting for $artifact_url" - for attempt in $(seq 1 60); do - # --max-time so a stalled connection cannot blow past the - # intended 30-minute bound. + # Bounded by elapsed time, not by an iteration count: with + # `--max-time 30` on the request, 60 iterations of request+sleep + # could run for a full hour and be killed by the job timeout + # before the actionable message below ever printed. + deadline=$(( SECONDS + 1800 )) + attempt=0 + while (( SECONDS < deadline )); do + attempt=$(( attempt + 1 )) if curl -fsI --max-time 30 "$artifact_url" >/dev/null; then echo "Released artifact is available on Maven Central" exit 0 fi - echo "Artifact not available yet, retrying in 30s ($attempt/60)" + echo "Artifact not available yet, retrying in 30s (attempt ${attempt})" sleep 30 done echo "Released artifact is not available on Maven Central after 30 minutes" - echo "The Central publish itself succeeded; re-run this job once it syncs." + echo "The Central publish itself succeeded — re-run this job once it syncs." exit 1 - name: Publish GitHub Release