diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 018cf6398..6969cadaa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,13 +84,6 @@ jobs: - name: Setup Gradle (cache) uses: gradle/actions/setup-gradle@v4 - # Packaging is a release artifact, not a pull-request gate. Keeping it on - # master avoids paying for a second full build on every PR update. - - name: Package slim runtime (fail fast) - if: github.event_name == 'push' - shell: bash - run: ./gradlew packageSlimCompilerDist --no-daemon --stacktrace - - name: Prepare bundled Lua runtime (Linux) if: runner.os == 'Linux' shell: bash @@ -114,6 +107,16 @@ jobs: shell: bash run: brew install lua + - name: Test release gate + if: runner.os == 'Linux' + shell: bash + run: python ../scripts/test-release.py + + - name: Test compiler version metadata + if: runner.os == 'Linux' + shell: bash + run: python ../scripts/test-version-metadata.py + - name: Test game-data helpers shell: bash run: ./gradlew -p ../HelperScripts test --no-daemon --stacktrace --quiet @@ -157,94 +160,3 @@ jobs: de.peeeq.wurstscript/test-output/determinism-second.lua if-no-files-found: ignore retention-days: 14 - - - name: Upload packaged artifact (per-OS) - if: github.event_name == 'push' - uses: actions/upload-artifact@v4 - with: - name: wurst-compiler-${{ matrix.os }} - path: | - de.peeeq.wurstscript/build/releases/*.zip - de.peeeq.wurstscript/build/releases/*.tar.gz - de.peeeq.wurstscript/build/releases/*.sha256 - if-no-files-found: error - retention-days: 7 - - nightly_release: - name: Nightly prerelease (master) - needs: build - if: github.ref == 'refs/heads/master' && github.event_name == 'push' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout (to update nightly tag) - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Move 'nightly' tag to this commit - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -f nightly "$GITHUB_SHA" - git push origin nightly --force - - - name: Download all packaged artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Gather files (zip only) - shell: bash - run: | - set -euo pipefail - mkdir -p upload - find artifacts -type f -name '*.zip' -print0 | xargs -0 -I{} cp "{}" upload/ - - - name: Verify we have all platform zips - shell: bash - run: | - shopt -s nullglob - zips=(upload/*.zip) - echo "Found ${#zips[@]} zip(s):"; ls -alh upload - if (( ${#zips[@]} < 4 )); then - echo "ERROR: expected Win + Linux + macOS arm64 + macOS x64 zips (got ${#zips[@]})." - exit 1 - fi - - - name: Normalize nightly filenames - shell: bash - run: | - set -euo pipefail - for f in upload/*.zip; do - base="$(basename "$f")" - stem="${base%.zip}" - new_stem="$(echo "$stem" | sed -E 's/-[0-9]+(\.[0-9]+)*-/-nightly-/' )" - new="upload/${new_stem}.zip" - if [[ "upload/$base" != "$new" ]]; then mv -f "upload/$base" "$new"; fi - done - echo "Renamed to:"; ls -alh upload - - # Keep the existing release metadata and stable download URLs. The release - # action PATCHes the release before uploading, which GitHub currently - # rejects for the Actions integration even with contents: write. - - name: Ensure Nightly Release exists - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if ! gh release view nightly >/dev/null 2>&1; then - gh release create nightly \ - --verify-tag \ - --prerelease \ - --title "Nightly Build (master)" \ - --notes $'Nightly build for the latest commit on `master`.\nThis release is automatically updated on each push.' - fi - - - name: Publish Nightly Release assets - shell: bash - env: - GH_TOKEN: ${{ github.token }} - run: gh release upload nightly upload/*.zip --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a172631e8..096d360d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +1,46 @@ -name: Release slim runtimes (jlink) +name: Release versioned compiler on: push: - tags: - - 'v*' - workflow_dispatch: + tags: ['v*'] permissions: - contents: write + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: + validate: + name: Validate release tag on master + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Test release gate + run: python scripts/test-release.py + - name: Validate tag and version + id: version + env: + RELEASE_REF: ${{ github.ref }} + run: | + git fetch origin master + version=$(python scripts/validate-release.py "$RELEASE_REF") + echo "version=$version" >> "$GITHUB_OUTPUT" + build: - name: Build ${{ matrix.plat }} + name: Test and package ${{ matrix.plat }} + needs: validate + permissions: + contents: read + checks: write strategy: fail-fast: false - # keep four platforms as you had matrix: include: - os: windows-latest @@ -25,52 +51,129 @@ jobs: plat: macos-x64 - os: macos-latest plat: macos-arm64 - runs-on: ${{ matrix.os }} - permissions: - contents: write env: GRADLE_OPTS: -Dorg.gradle.daemon=false + JAVA_TOOL_OPTIONS: --enable-native-access=ALL-UNNAMED + RELEASE_VERSION: ${{ needs.validate.outputs.version }} defaults: run: working-directory: de.peeeq.wurstscript - + shell: bash steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: - fetch-depth: 0 # your version task uses git describe - + ref: ${{ github.sha }} + fetch-depth: 0 - name: Setup JDK 27 uses: actions/setup-java@v4 with: # Temurin 27 has no macOS x64 build; Zulu keeps the Intel artifact supported. distribution: ${{ matrix.os == 'macos-15-intel' && 'zulu' || 'temurin' }} java-version: '27' + # Ubuntu's system-modified JDK cannot always serve as a jlink input. + - name: Install portable Temurin for Linux packaging + if: runner.os == 'Linux' + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/temurin27" + curl -fsSL 'https://github.com/adoptium/temurin27-binaries/releases/download/jdk-27%2B35/OpenJDK27U-jdk_x64_linux_hotspot_27_35.tar.gz' -o "$RUNNER_TEMP/temurin27/jdk.tar.gz" + tar -xzf "$RUNNER_TEMP/temurin27/jdk.tar.gz" -C "$RUNNER_TEMP/temurin27" + PORTABLE_JAVA_HOME=$(find "$RUNNER_TEMP/temurin27" -maxdepth 1 -type d -name 'jdk-27*' | head -n1) + echo "PORTABLE_JAVA_HOME=$PORTABLE_JAVA_HOME" >> "$GITHUB_ENV" + echo "$PORTABLE_JAVA_HOME/bin" >> "$GITHUB_PATH" + - name: Pin Gradle toolchain + run: | + ACTIVE_JAVA_HOME="${PORTABLE_JAVA_HOME:-$JAVA_HOME}" + echo "JAVA_HOME=${ACTIVE_JAVA_HOME}" >> "$GITHUB_ENV" + echo "${ACTIVE_JAVA_HOME}/bin" >> "$GITHUB_PATH" + echo "org.gradle.java.installations.paths=${ACTIVE_JAVA_HOME}" >> gradle.properties + echo "org.gradle.java.installations.auto-detect=false" >> gradle.properties + - uses: gradle/actions/wrapper-validation@v4 + - uses: gradle/actions/setup-gradle@v4 + - name: Install Lua 5.3 for Linux tests + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends lua5.3 + chmod +x src/test/resources/lua53 src/test/resources/luac53 + - name: Install Lua for macOS tests + if: runner.os == 'macOS' + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + run: brew install lua + - name: Test game-data helpers + run: ./gradlew -p ../HelperScripts test --no-daemon --stacktrace --quiet + - name: Test compiler and package runtime + run: ./gradlew test packageSlimCompilerDist --no-daemon --stacktrace + - name: Verify packaged compiler version + run: | + set -euo pipefail + JAVA_BIN=build/dist/slim-${{ matrix.plat }}/wurst-runtime/bin/java + if [[ "$RUNNER_OS" == "Windows" ]]; then JAVA_BIN="$JAVA_BIN.exe"; fi + actual=$("$JAVA_BIN" -jar build/dist/slim-${{ matrix.plat }}/wurst-compiler/wurstscript.jar -version) + # Native Java prints CRLF on Windows; remove any CR left by command substitution. + actual=${actual%$'\r'} + test "$actual" = "$RELEASE_VERSION" + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.plat }} + path: de.peeeq.wurstscript/build/releases/wurst-compiler-${{ needs.validate.outputs.version }}-${{ matrix.plat }}.zip + if-no-files-found: error + retention-days: 14 + - name: Report test results + if: always() + uses: mikepenz/action-junit-report@v4 + with: + report_paths: de.peeeq.wurstscript/build/test-results/**/*.xml + check_name: Release tests (${{ matrix.plat }}) - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v4 - - - name: Setup Gradle cache - uses: gradle/actions/setup-gradle@v4 - - # Build fat jar + jdeps + jlink + package + checksum for this OS - - name: Package slim runtime (per-OS) - run: ./gradlew --no-daemon --stacktrace packageSlimCompilerDist - - - name: Upload to GitHub Release - uses: softprops/action-gh-release@v2 + publish: + name: Publish complete versioned release + needs: [validate, build] + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + steps: + - uses: actions/checkout@v4 with: - # Attaches the per-OS files produced on this runner - files: | - de.peeeq.wurstscript/build/releases/wurst-compiler-*-*.zip - de.peeeq.wurstscript/build/releases/wurst-compiler-*-*.tar.gz - de.peeeq.wurstscript/build/releases/*.sha256 - tag_name: ${{ github.ref_name }} # ensures assets attach to the pushed v* tag - name: ${{ github.ref_name }} - draft: false - prerelease: false - generate_release_notes: true + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Revalidate release commit env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + RELEASE_REF: ${{ github.ref }} + run: | + git fetch origin master + python scripts/validate-release.py "$RELEASE_REF" + - uses: actions/download-artifact@v4 + with: + pattern: release-* + path: release-assets + merge-multiple: true + - name: Verify complete platform set and checksum assets + working-directory: release-assets + run: | + set -euo pipefail + for platform in win-x64 linux-x64 macos-x64 macos-arm64; do + file="wurst-compiler-$RELEASE_VERSION-$platform.zip" + test -s "$file" + sha256sum "$file" > "$file.sha256" + done + # Publish only after every platform passed; never update a published release. + - name: Publish release + run: | + set -euo pipefail + if gh release view "$RELEASE_TAG" --json isDraft > release-state.json; then + if ! python -c 'import json,sys; sys.exit(0 if json.load(open("release-state.json"))["isDraft"] else 1)'; then + echo "Release $RELEASE_TAG is already published; refusing to replace its assets." + exit 1 + fi + else + gh release create "$RELEASE_TAG" --verify-tag --draft --title "$RELEASE_TAG" --generate-notes + fi + gh release upload "$RELEASE_TAG" release-assets/* --clobber + gh release edit "$RELEASE_TAG" --draft=false --latest diff --git a/de.peeeq.wurstscript/build.gradle b/de.peeeq.wurstscript/build.gradle index 3162dda34..fd5c21fa7 100644 --- a/de.peeeq.wurstscript/build.gradle +++ b/de.peeeq.wurstscript/build.gradle @@ -26,7 +26,10 @@ import java.util.regex.Pattern ext.mainClassName = "de.peeeq.wurstio.Main" -version = "1.9.0.0" +version = providers.fileContents(layout.projectDirectory.file('version.txt')).asText.get().trim() +if (!(version ==~ /(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)/)) { + throw new GradleException('version.txt must contain MAJOR.MINOR.PATCH') +} java { toolchain { @@ -247,22 +250,21 @@ tasks.register('versionInfoFile') { workingDir(repoDir) commandLine('git', 'rev-parse', '--short=8', 'HEAD') }.standardOutput.asText.map { it.trim() } - def description = providers.exec { + def releaseTag = providers.exec { workingDir(repoDir) - commandLine('git', 'describe', '--tags', '--always') + commandLine('git', 'tag', '--points-at', 'HEAD', '--list', "v${versionString}") }.standardOutput.asText.map { it.trim() } inputs.property("projectVersion", versionString) inputs.property("gitRevision", revision) inputs.property("gitShortRevision", shortRevision) - inputs.property("gitDescription", description) + inputs.property("releaseTag", releaseTag) doLast { def rev8 = shortRevision.get() def revLong = revision.get() - def tag = description.get() - - def wurstVersion = "${versionString}-${tag}" + def wurstVersion = releaseTag.get() == "v${versionString}" + ? versionString : "${versionString}-dev+${rev8}" dir.mkdirs() out.text = """ diff --git a/de.peeeq.wurstscript/version.txt b/de.peeeq.wurstscript/version.txt new file mode 100644 index 000000000..227cea215 --- /dev/null +++ b/de.peeeq.wurstscript/version.txt @@ -0,0 +1 @@ +2.0.0 diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 000000000..86e96ab0f --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,31 @@ +# Compiler releases + +Master is the development branch. Merging a pull request runs CI but does not publish or move a release tag. The extension installs published semantic-version releases, and users can select an older compiler through **Wurst: Choose Compiler Version**. + +The release baseline is **2.0.0**, stored in `de.peeeq.wurstscript/version.txt`. Use three numeric components without a `v` prefix. Increment the patch for compatible fixes, the minor for compatible features, and the major for breaking compiler/language changes. Choose the version as part of release preparation; master can contain unreleased work throughout that interval. + +## Publish a release + +1. Merge the intended changes and version bump to master. Review the release scope and CI results. +2. Fetch master and tags, then check out the intended master commit. A release can use an older master commit when newer work is not ready. +3. Create an annotated tag matching that commit's version file, for example `git tag -a v2.0.0 -m "WurstScript 2.0.0"`. +4. Push that one tag: `git push origin v2.0.0`. +5. Watch **Release versioned compiler**. It validates the tag, version file, and master ancestry, runs tests and packages Windows x64, Linux x64, macOS x64, and macOS arm64. Publication waits for every platform to pass. + +The workflow assembles the complete assets in a draft release, uploads checksums, then publishes it. A published version is never overwritten. For a failed build, rerun the failed workflow; for a defect in a published compiler, bump the version and publish a new tag. Do not move an existing release tag. + +## Version metadata + +At the exact `v2.0.0` tag, `wurstscript -version` prints `2.0.0`. An untagged build prints `2.0.0-dev+`, so a development compiler cannot be mistaken for a released compiler. Full and abbreviated commit metadata remain available separately in `CompileTimeInfo`. + +Each release has `wurst-compiler-VERSION-PLATFORM.zip` plus `.sha256` assets. The archive keeps the runtime, compiler, and launcher layout used by the extension. + +## Transition from nightly + +The old nightly release remains available for existing installations, but master no longer updates it. Merge the compiler release workflow, publish the first `v2.0.0` release, then ship the extension's release-selection changes. Before the first versioned release exists, that extension has no stable compiler to install and reports that clearly. + +Automatic checks offer newer released versions when following latest stable. Explicit selection supports upgrading or downgrading and pins that version until the user chooses **Follow latest stable**. Version changes retain the existing staged installation and rollback behavior. Source builds remain identifiable as development versions; no published release is created by this migration PR itself. + +## Local checks + +Run `python scripts/test-release.py` for the release gate's Git-history regression tests, and `python scripts/test-version-metadata.py` to exercise the production Gradle metadata task against nightly, exact release, and post-release commits. From `de.peeeq.wurstscript`, run `./gradlew test --tests de.peeeq.wurstio.MainTests shadowJar` to check version output and build the compiler. The four-platform release workflow supplies the release packaging checks. diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 000000000..c18dd8d83 --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/test-release.py b/scripts/test-release.py new file mode 100644 index 000000000..1e99c9f8f --- /dev/null +++ b/scripts/test-release.py @@ -0,0 +1,68 @@ +"""Release gate regression tests using real temporary Git histories.""" +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location("validate_release", Path(__file__).with_name("validate-release.py")) +validator = importlib.util.module_from_spec(spec) +spec.loader.exec_module(validator) + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.repo = Path(self.temp.name) + self.git("init", "--initial-branch=master") + self.git("config", "user.name", "Release test") + self.git("config", "user.email", "release-test@example.invalid") + (self.repo / "de.peeeq.wurstscript").mkdir() + (self.repo / "de.peeeq.wurstscript/version.txt").write_text("2.0.0\n") + self.git("add", ".") + self.git("commit", "-m", "Version baseline") + self.git("update-ref", "refs/remotes/origin/master", "HEAD") + + def git(self, *args): + return subprocess.check_output(["git", "-C", str(self.repo), *args], text=True, stderr=subprocess.STDOUT).strip() + + def test_annotated_release_tag_on_master_is_accepted(self): + self.git("tag", "-a", "v2.0.0", "-m", "Release 2.0.0") + self.assertEqual(validator.validate_release(self.repo, "refs/tags/v2.0.0"), "2.0.0") + + def test_lightweight_tag_on_master_ancestor_is_accepted(self): + self.git("tag", "v2.0.0") + release = self.git("rev-parse", "HEAD") + self.git("commit", "--allow-empty", "-m", "Later master change") + self.git("update-ref", "refs/remotes/origin/master", "HEAD") + self.git("checkout", "--detach", release) + self.assertEqual(validator.validate_release(self.repo, "refs/tags/v2.0.0"), "2.0.0") + + def test_branch_and_non_semantic_tags_are_rejected(self): + for ref in ["refs/heads/master", "refs/tags/nightly", "refs/tags/v1.9.0.0", "refs/tags/v02.0.0", "refs/tags/v2.0.0-rc.1"]: + with self.subTest(ref=ref), self.assertRaises(ValueError): + validator.validate_release(self.repo, ref) + + def test_tag_version_must_match_checked_in_version(self): + self.git("tag", "v2.0.1") + with self.assertRaisesRegex(ValueError, "version.txt"): + validator.validate_release(self.repo, "refs/tags/v2.0.1") + + def test_tag_must_point_to_checkout(self): + self.git("tag", "v2.0.0") + self.git("commit", "--allow-empty", "-m", "Untagged commit") + self.git("update-ref", "refs/remotes/origin/master", "HEAD") + with self.assertRaisesRegex(ValueError, "checkout"): + validator.validate_release(self.repo, "refs/tags/v2.0.0") + + def test_tag_on_unmerged_branch_is_rejected(self): + self.git("switch", "-c", "feature") + self.git("commit", "--allow-empty", "-m", "Unmerged change") + self.git("tag", "v2.0.0") + with self.assertRaisesRegex(ValueError, "master"): + validator.validate_release(self.repo, "refs/tags/v2.0.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-version-metadata.py b/scripts/test-version-metadata.py new file mode 100644 index 000000000..70fae3e80 --- /dev/null +++ b/scripts/test-version-metadata.py @@ -0,0 +1,46 @@ +"""Exercise the production Gradle metadata task in an isolated tagged Git history.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile + +root = Path(__file__).resolve().parent.parent +build = (root / "de.peeeq.wurstscript/build.gradle").read_text() +version_config = build[build.index("version = providers.fileContents"):build.index("\njava {")] +start = build.index("tasks.register('versionInfoFile')") +end = build.index("/** -------- Aggregate generation", start) +metadata_task = build[start:end] +wrapper = root / "de.peeeq.wurstscript" / ("gradlew.bat" if os.name == "nt" else "gradlew") + +with tempfile.TemporaryDirectory(prefix="wurst-version-metadata-") as temp: + repository = Path(temp) + project = repository / "de.peeeq.wurstscript" + project.mkdir() + (project / "settings.gradle").write_text("rootProject.name = 'version-metadata-test'\n") + (project / "build.gradle").write_text("def genDir = file('src-gen')\n" + version_config + metadata_task) + (project / "version.txt").write_text("2.0.0\n") + + def git(*args): + return subprocess.check_output(["git", "-C", str(repository), *args], text=True, stderr=subprocess.STDOUT).strip() + + git("init", "--initial-branch=master") + git("config", "user.name", "Version test") + git("config", "user.email", "version-test@example.invalid") + git("add", ".") + git("commit", "-m", "Version baseline") + git("tag", "nightly") + + def generated_version(): + subprocess.run([str(wrapper), "versionInfoFile", "--console=plain", "--configuration-cache"], cwd=project, check=True) + content = (project / "src-gen/de/peeeq/wurstscript/CompileTimeInfo.java").read_text() + return re.search(r'public static final String version="([^"]+)"', content).group(1) + + development = f"2.0.0-dev+{git('rev-parse', '--short=8', 'HEAD')}" + assert generated_version() == development, "nightly must not identify a stable compiler" + git("tag", "-a", "v2.0.0", "-m", "Release baseline") + assert generated_version() == "2.0.0", "the exact release tag must emit its semantic version" + git("commit", "--allow-empty", "-m", "Unreleased work") + assert generated_version() == f"2.0.0-dev+{git('rev-parse', '--short=8', 'HEAD')}" + +print("Development, exact release, and post-release metadata checks passed.") diff --git a/scripts/validate-release.py b/scripts/validate-release.py new file mode 100644 index 000000000..a2c28f94c --- /dev/null +++ b/scripts/validate-release.py @@ -0,0 +1,34 @@ +"""Validate the versioned tag before any release build or publication.""" +from pathlib import Path +import re +import subprocess +import sys + + +def validate_release(repository, ref): + match = re.fullmatch(r"refs/tags/v((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))", ref) + if not match: + raise ValueError("Releases require a vMAJOR.MINOR.PATCH tag; branches and nightly tags cannot publish.") + version = match.group(1) + repository = Path(repository) + if (repository / "de.peeeq.wurstscript/version.txt").read_text().strip() != version: + raise ValueError("The tag must match de.peeeq.wurstscript/version.txt at the tagged commit.") + + def git(*args): + return subprocess.check_output(["git", "-C", str(repository), *args], text=True).strip() + + head = git("rev-parse", "HEAD") + if git("rev-parse", f"{ref}^{{commit}}") != head: + raise ValueError("The release tag must point to the current checkout.") + ancestor = subprocess.run(["git", "-C", str(repository), "merge-base", "--is-ancestor", head, "origin/master"], check=False) + if ancestor.returncode != 0: + raise ValueError("Release tags must point to commits on origin/master.") + return version + + +if __name__ == "__main__": + try: + print(validate_release(Path(__file__).resolve().parent.parent, sys.argv[1])) + except (ValueError, OSError, subprocess.CalledProcessError, IndexError) as error: + print(f"Release validation failed: {error}", file=sys.stderr) + sys.exit(1)