diff --git a/.github/workflows/java-agent.yml b/.github/workflows/java-agent.yml index 4ecb7da..057f1cb 100644 --- a/.github/workflows/java-agent.yml +++ b/.github/workflows/java-agent.yml @@ -1,11 +1,20 @@ 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] +# 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 fba8815..082930a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,30 +1,88 @@ 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 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. +# +# 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 + # 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. + GPG_KEY_ID: 8541784E4EC36FB8 steps: - name: Checkout uses: actions/checkout@v4 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}" + # 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[-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" - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -32,65 +90,200 @@ 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). 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 + gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + 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 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 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 java agent + - 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: 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 + # 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: | - set -euxo pipefail - version="${{ steps.ver.outputs.version }}" + set -euo pipefail 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/ + 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 + # 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 }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: >- + mvn -B -ntp -P release -DskipTests deploy + -Dgpg.keyname="$GPG_KEY_ID" + + 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: ${{ 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" + # 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 ${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." + exit 1 + - 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/* 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