diff --git a/.github/actions/setup-godot/action.yml b/.github/actions/setup-godot/action.yml index b12f973..ecf9e00 100644 --- a/.github/actions/setup-godot/action.yml +++ b/.github/actions/setup-godot/action.yml @@ -4,6 +4,9 @@ inputs: godot-version: description: 'Godot version to install (e.g., 4.4.1)' required: true + godot-sha256: + description: 'SHA-256 of the Linux x86_64 release ZIP' + required: true install-templates: description: 'Whether to install export templates' required: false @@ -17,20 +20,24 @@ runs: uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: /usr/local/bin/godot - key: godot-${{ inputs.godot-version }}-${{ runner.os }} + key: godot-${{ inputs.godot-version }}-${{ inputs.godot-sha256 }}-${{ runner.os }} - name: Install Godot if: steps.cache-godot.outputs.cache-hit != 'true' shell: bash run: | - wget -q https://github.com/godotengine/godot/releases/download/${{ inputs.godot-version }}-stable/Godot_v${{ inputs.godot-version }}-stable_linux.x86_64.zip - unzip -q Godot_v${{ inputs.godot-version }}-stable_linux.x86_64.zip - chmod +x Godot_v${{ inputs.godot-version }}-stable_linux.x86_64 - sudo mv Godot_v${{ inputs.godot-version }}-stable_linux.x86_64 /usr/local/bin/godot + set -euo pipefail + archive="Godot_v${{ inputs.godot-version }}-stable_linux.x86_64.zip" + curl --fail --location --silent --show-error --output "$archive" \ + "https://github.com/godotengine/godot/releases/download/${{ inputs.godot-version }}-stable/$archive" + printf '%s %s\n' '${{ inputs.godot-sha256 }}' "$archive" | sha256sum --check + unzip -q "$archive" + chmod +x "Godot_v${{ inputs.godot-version }}-stable_linux.x86_64" + sudo mv "Godot_v${{ inputs.godot-version }}-stable_linux.x86_64" /usr/local/bin/godot - name: Verify Godot installation shell: bash - run: godot --version + run: test "$(godot --version)" = '${{ inputs.godot-version }}.stable.official.ed1daf0bf' - name: Cache Godot export templates if: inputs.install-templates == 'true' diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index ecdedef..d1f36b5 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -27,13 +27,16 @@ runs: run: | set -euo pipefail test -f addon/plugin.cfg + python3 scripts/package_addon.py build dist/@aviorstudio_gd-telemetry.zip + python3 scripts/package_addon.py verify dist/@aviorstudio_gd-telemetry.zip # Godot comes from a LOCAL action, copied from the one castledrop and prizm # carry. The version used to be a download URL written into ci.yml -- the # engine this addon is tested against lived in workflow YAML. - uses: ./.github/actions/setup-godot with: - godot-version: '4.4.1' + godot-version: '4.7.2' + godot-sha256: 'cadd3204e728a35d3f13adb7fd0d7902636b79f6b95c40c265eb73b6c35329e4' # No `if: hashFiles(...)` guard. This step used to skip itself when # tests/test.sh was absent, which is indistinguishable from the script being @@ -41,4 +44,10 @@ runs: # suite, so the step runs unconditionally and a missing script now fails. - name: Godot tests shell: bash - run: ./tests/test.sh + run: | + ./tests/gate_controls.sh + ./tests/test.sh + + - name: Packaged addon install and editor lifecycle + shell: bash + run: ./tests/package_controls.sh dist/@aviorstudio_gd-telemetry.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0ef0df..95a70da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,99 +7,90 @@ on: description: Version bump required: true type: choice - options: - - patch - - minor - - major + options: [patch, minor, major] permissions: - contents: write + contents: read concurrency: release-${{ github.repository }} jobs: - release: + prepare: runs-on: ubuntu-latest - # Bounded, so a step that hangs fails here rather than sitting until the - # runner's own timeout hours later. timeout-minutes: 20 + outputs: + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - name: Determine release version id: release env: BUMP: ${{ inputs.bump }} run: | set -euo pipefail - if [ "$GITHUB_REF" != "refs/heads/main" ]; then - echo 'Run releases from the main branch.' >&2 - exit 1 - fi + test "$GITHUB_REF" = refs/heads/main || { echo 'Run releases from main.' >&2; exit 1; } git fetch --tags --force latest="$(git tag --list 'v[0-9]*' | sed -E 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" if [ -z "$latest" ]; then - version="0.0.1" + version=0.0.1 else IFS=. read -r major minor patch <<< "$latest" case "$BUMP" in major) major=$((major + 1)); minor=0; patch=0 ;; minor) minor=$((minor + 1)); patch=0 ;; patch) patch=$((patch + 1)) ;; - *) echo "Unsupported bump: $BUMP" >&2; exit 1 ;; + *) exit 1 ;; esac - version="${major}.${minor}.${patch}" - fi - tag="v${version}" - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "Tag already exists: $tag" >&2 - exit 1 + version="$major.$minor.$patch" fi + tag="v$version" + ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null plugin_version="$(sed -n -E 's/^version="([^"]+)"/\1/p' addon/plugin.cfg | head -n 1)" - if [ "$plugin_version" != "$version" ]; then - echo "addon/plugin.cfg version is $plugin_version, but the next $BUMP release is $version." >&2 - echo "Update addon/plugin.cfg to version=\"$version\", commit it, then rerun this workflow." >&2 - exit 1 - fi + test "$plugin_version" = "$version" || { echo "plugin.cfg is $plugin_version; expected $version" >&2; exit 1; } echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" - - # The same checks CI runs, from the same definition. This workflow used - # to package and publish without running any of them -- the only thing - # between a broken commit and the GDAM registry was whether somebody had - # looked at CI. Running them here against this exact commit is the point: - # CI passing on this SHA earlier is a claim about that run. - - name: Test + - name: Test exact release commit and package bytes uses: ./.github/actions/test + - name: Record tested artifact identity + run: (cd dist && sha256sum @aviorstudio_gd-telemetry.zip > @aviorstudio_gd-telemetry.zip.sha256) + - name: Preserve tested bytes + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-${{ steps.release.outputs.tag }} + path: | + dist/@aviorstudio_gd-telemetry.zip + dist/@aviorstudio_gd-telemetry.zip.sha256 + dist/installed-tree.sha256 + if-no-files-found: error - - name: Package addon - run: | - set -euo pipefail - test -f addon/plugin.cfg - repo_owner="${GITHUB_REPOSITORY%%/*}" - addon_dir="@${repo_owner}_${GITHUB_REPOSITORY#*/}" - package_root="dist/${addon_dir}" - mkdir -p "$package_root" - cp addon/plugin.cfg addon/plugin.gd "$package_root/" - cp addon/*.uid "$package_root/" 2>/dev/null || true - if [ -f addon/autoload.gd ]; then cp addon/autoload.gd "$package_root/"; fi - if [ -d addon/src ]; then cp -R addon/src "$package_root/"; fi - (cd "$package_root" && zip -r "../${addon_dir}.zip" .) - - - name: Create GitHub Release + publish: + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Recover tested bytes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-${{ needs.prepare.outputs.tag }} + path: dist + - name: Verify tested bytes + run: (cd dist && sha256sum --check @aviorstudio_gd-telemetry.zip.sha256) + - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ steps.release.outputs.tag }} - run: gh release create "$TAG" dist/*.zip --target "$GITHUB_SHA" --title "$TAG" --notes "Release $TAG" - + TAG: ${{ needs.prepare.outputs.tag }} + run: gh release create "$TAG" dist/@aviorstudio_gd-telemetry.zip dist/@aviorstudio_gd-telemetry.zip.sha256 dist/installed-tree.sha256 --target "$GITHUB_SHA" --title "$TAG" --notes "Release $TAG from tested bytes" - name: Install GDAM - uses: aviorstudio/gdam-actions/install@v0.0.2 - + uses: aviorstudio/gdam-actions/install@d735444eb470194585def44521d5d91df2260e63 # v0.0.2 - name: Publish to GDAM - uses: aviorstudio/gdam-actions/publish@v0.0.2 + uses: aviorstudio/gdam-actions/publish@d735444eb470194585def44521d5d91df2260e63 # v0.0.2 with: - version: ${{ steps.release.outputs.version }} - tag: ${{ steps.release.outputs.tag }} + version: ${{ needs.prepare.outputs.version }} + tag: ${{ needs.prepare.outputs.tag }} secret-key: ${{ secrets.GDAM_SECRET_KEY }} diff --git a/.gitignore b/.gitignore index e43b0f9..b862187 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +dist/ +__pycache__/ diff --git a/README.md b/README.md index 7052490..10f41a1 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,12 @@ Run locally with: ./tests/test.sh ``` -CI runs the same test script when available. +**Correction (fieldsofrevik#156):** CI and release now require the Godot +4.7.2 suite rather than conditionally skipping a missing script. They also run +versioned negative runner controls, build and inspect the closed-manifest ZIP, +and exercise the installed ZIP through plugin enable/restart, smoke, +disable/restart lifecycle checks. Godot downloads are checksum-verified and +publication uploads the exact ZIP tested by the release job. ## License diff --git a/package-manifest.txt b/package-manifest.txt new file mode 100644 index 0000000..82a6a71 --- /dev/null +++ b/package-manifest.txt @@ -0,0 +1,5 @@ +plugin.cfg +plugin.gd +plugin.gd.uid +src/telemetry_module.gd +src/telemetry_module.gd.uid diff --git a/scripts/package_addon.py b/scripts/package_addon.py new file mode 100755 index 0000000..a8b4988 --- /dev/null +++ b/scripts/package_addon.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import hashlib +import pathlib +import stat +import sys +import zipfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MANIFEST = tuple(line.strip() for line in (ROOT / "package-manifest.txt").read_text().splitlines() if line.strip()) + +def verify(archive: pathlib.Path) -> None: + with zipfile.ZipFile(archive) as bundle: + infos = bundle.infolist() + names = [info.filename for info in infos] + for info in infos: + path = pathlib.PurePosixPath(info.filename) + if path.is_absolute() or ".." in path.parts or info.filename.endswith("/"): + raise ValueError(f"unsafe archive path: {info.filename}") + if stat.S_ISLNK(info.external_attr >> 16): + raise ValueError(f"symlink forbidden: {info.filename}") + if len(names) != len(set(names)): + raise ValueError("duplicate archive member") + if tuple(sorted(names)) != tuple(sorted(MANIFEST)): + raise ValueError(f"closed manifest mismatch: {names}") + +def build(archive: pathlib.Path) -> None: + archive.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as bundle: + for name in sorted(MANIFEST): + source = ROOT / "addon" / name + if not source.is_file() or source.is_symlink(): + raise ValueError(f"missing or unsafe source: {name}") + info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + bundle.writestr(info, source.read_bytes(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + verify(archive) + print(f"PACKAGE_SHA256={hashlib.sha256(archive.read_bytes()).hexdigest()}") + +if len(sys.argv) != 3 or sys.argv[1] not in {"build", "verify"}: + raise SystemExit("usage: package_addon.py build|verify ARCHIVE") +target = pathlib.Path(sys.argv[2]) +build(target) if sys.argv[1] == "build" else verify(target) diff --git a/tests/fixtures/assertion_overwrite.fixture.gd b/tests/fixtures/assertion_overwrite.fixture.gd new file mode 100644 index 0000000..76168b1 --- /dev/null +++ b/tests/fixtures/assertion_overwrite.fixture.gd @@ -0,0 +1,5 @@ +extends SceneTree +func _initialize() -> void: + push_error("intentional failed assertion") + quit(1) + quit(0) diff --git a/tests/fixtures/good.fixture.gd b/tests/fixtures/good.fixture.gd new file mode 100644 index 0000000..9645fb6 --- /dev/null +++ b/tests/fixtures/good.fixture.gd @@ -0,0 +1,4 @@ +extends SceneTree +func _initialize() -> void: + print("TEST_REACHED:good.gd") + quit(0) diff --git a/tests/fixtures/hang.fixture.gd b/tests/fixtures/hang.fixture.gd new file mode 100644 index 0000000..5163f9d --- /dev/null +++ b/tests/fixtures/hang.fixture.gd @@ -0,0 +1,4 @@ +extends SceneTree +func _initialize() -> void: + while true: + await process_frame diff --git a/tests/fixtures/parse_error.fixture.gd b/tests/fixtures/parse_error.fixture.gd new file mode 100644 index 0000000..ad90b3c --- /dev/null +++ b/tests/fixtures/parse_error.fixture.gd @@ -0,0 +1,3 @@ +extends SceneTree +func _initialize() -> void + quit(0) diff --git a/tests/fixtures/runtime_error_zero.fixture.gd b/tests/fixtures/runtime_error_zero.fixture.gd new file mode 100644 index 0000000..056d67b --- /dev/null +++ b/tests/fixtures/runtime_error_zero.fixture.gd @@ -0,0 +1,5 @@ +extends SceneTree +func _initialize() -> void: + push_error("intentional gate control") + print("TEST_REACHED:runtime_error_zero.gd") + quit(0) diff --git a/tests/gate_controls.sh b/tests/gate_controls.sh new file mode 100755 index 0000000..4043b52 --- /dev/null +++ b/tests/gate_controls.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +expect_fail() { + if TIMEOUT_SECONDS=1 "$DIR/run_fixture.sh" "$1"; then + echo "negative control unexpectedly passed: $1" >&2 + exit 1 + fi + echo "EXPECTED_GATE_FAILURE:$(basename "$1")" +} +expect_fail "$DIR/fixtures/runtime_error_zero.fixture.gd" +expect_fail "$DIR/fixtures/assertion_overwrite.fixture.gd" +expect_fail "$DIR/fixtures/parse_error.fixture.gd" +expect_fail "$DIR/fixtures/hang.fixture.gd" +empty="$(mktemp -d)" +trap 'rm -rf "$empty"' EXIT +if TESTS_DIR="$empty" "$DIR/test.sh"; then + echo "missing-suite control unexpectedly passed" >&2 + exit 1 +fi +echo "EXPECTED_GATE_FAILURE:missing-suite" +"$DIR/run_fixture.sh" "$DIR/fixtures/good.fixture.gd" +echo "RESTORED_GATE_PASS:good.fixture.gd" diff --git a/tests/package_controls.sh b/tests/package_controls.sh new file mode 100755 index 0000000..af69c48 --- /dev/null +++ b/tests/package_controls.sh @@ -0,0 +1,78 @@ +#!/bin/bash +set -euo pipefail +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +ARCHIVE="${1:?release ZIP required}" +GODOT="${GODOT_BIN:-godot}" +python3 "$ROOT/scripts/package_addon.py" verify "$ARCHIVE" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +python3 - "$ARCHIVE" "$tmp" <<'PY' +import pathlib, stat, sys, zipfile +archive, root = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) +for name, member, symlink in (("traversal.zip", "../escape", False), ("undeclared.zip", "extra.txt", False), ("symlink.zip", "link", True)): + out = root / name + with zipfile.ZipFile(out, "w") as z: + if name == "undeclared.zip": + with zipfile.ZipFile(archive) as source: + for info in source.infolist(): + z.writestr(info, source.read(info.filename)) + info = zipfile.ZipInfo(member) + if symlink: + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + z.writestr(info, b"x") +PY +for bad in "$tmp"/*.zip; do + if python3 "$ROOT/scripts/package_addon.py" verify "$bad"; then + echo "unsafe package unexpectedly passed: $bad" >&2 + exit 1 + fi + echo "EXPECTED_PACKAGE_FAILURE:$(basename "$bad")" +done +project="$tmp/project" +addon="$project/addons/@aviorstudio_gd-telemetry" +mkdir -p "$addon" +unzip -q "$ARCHIVE" -d "$addon" +cat > "$project/project.godot" <<'EOF' +[application] +config/name="gd-telemetry package fixture" +[editor_plugins] +enabled=PackedStringArray("@aviorstudio_gd-telemetry") +[consumer] +marker="preserve-me" +EOF +cat > "$project/smoke.gd" <<'EOF' +extends SceneTree +const Telemetry = preload("res://addons/@aviorstudio_gd-telemetry/src/telemetry_module.gd") +func _initialize() -> void: + var telemetry := Telemetry.new() + var event := telemetry.build_event(1, "info", "c", "s", "installed", {}) + if telemetry.to_dict(event).get("message") != "installed": + quit(1) + return + print("PACKAGE_SMOKE_REACHED") + quit(0) +EOF +run_editor() { timeout --foreground 60s "$GODOT" --headless --editor --path "$project" --quit; } +run_editor +run_editor +timeout --foreground 60s "$GODOT" --headless --path "$project" --script "$project/smoke.gd" | tee "$tmp/smoke.log" +grep -Fq PACKAGE_SMOKE_REACHED "$tmp/smoke.log" +python3 - "$project/project.godot" <<'PY' +import pathlib, sys +p=pathlib.Path(sys.argv[1]) +p.write_text(p.read_text().replace('enabled=PackedStringArray("@aviorstudio_gd-telemetry")', 'enabled=PackedStringArray()')) +PY +run_editor +run_editor +grep -Fq 'marker="preserve-me"' "$project/project.godot" +! grep -Fq '@aviorstudio_gd-telemetry' "$project/project.godot" +python3 - "$addon" "$ROOT/dist/installed-tree.sha256" <<'PY' +import hashlib, pathlib, sys +root, output = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) +h=hashlib.sha256() +for p in sorted(x for x in root.rglob('*') if x.is_file()): + h.update(p.relative_to(root).as_posix().encode()+b'\0'+hashlib.sha256(p.read_bytes()).digest()) +output.write_text(h.hexdigest()+"\n") +print("INSTALLED_TREE_SHA256="+h.hexdigest()) +PY diff --git a/tests/run_fixture.sh b/tests/run_fixture.sh new file mode 100755 index 0000000..0095939 --- /dev/null +++ b/tests/run_fixture.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +GODOT="${GODOT_BIN:-godot}" +fixture="$1" +log="$(mktemp)" +trap 'rm -f "$log"' EXIT +status=0 +timeout --foreground "${TIMEOUT_SECONDS:-5}s" "$GODOT" --headless --path "$ROOT_DIR" --script "$fixture" >"$log" 2>&1 || status=$? +cat "$log" +if [ "$status" -ne 0 ]; then + exit 1 +fi +if grep -Eq '(^| )ERROR:|SCRIPT ERROR:|USER ERROR:' "$log"; then + exit 1 +fi +sentinel="$(basename "$fixture" | sed 's/\.fixture//')" +if ! grep -Fq "TEST_REACHED:$sentinel" "$log"; then + exit 1 +fi diff --git a/tests/telemetry_module_test.gd b/tests/telemetry_module_test.gd index 930fbac..e5acc7f 100644 --- a/tests/telemetry_module_test.gd +++ b/tests/telemetry_module_test.gd @@ -10,6 +10,7 @@ func _initialize() -> void: await _test_auto_flush_lifecycle(failures) if failures.is_empty(): + print("TEST_REACHED:telemetry_module_test.gd") print("PASS gd-telemetry telemetry_module_test") quit(0) return diff --git a/tests/test.sh b/tests/test.sh index b139473..25301b2 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -2,12 +2,38 @@ set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +TESTS_DIR="${TESTS_DIR:-$SCRIPT_DIR}" GODOT="${GODOT_BIN:-godot}" FAILURES=0 -for test in "$SCRIPT_DIR"/*_test.gd; do +REACHED=0 +TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-60}" +shopt -s nullglob +tests=("$TESTS_DIR"/*_test.gd) +if [ "${#tests[@]}" -eq 0 ]; then + echo "ERROR: no Godot test scripts found" >&2 + exit 1 +fi +for test in "${tests[@]}"; do echo "Running $(basename "$test")..." - if ! "$GODOT" --headless --path "$ROOT_DIR" --script "$test" 2>&1; then + log="$(mktemp)" + status=0 + timeout --foreground "${TIMEOUT_SECONDS}s" "$GODOT" --headless --path "$ROOT_DIR" --script "$test" >"$log" 2>&1 || status=$? + cat "$log" + if [ "$status" -ne 0 ]; then + echo "ERROR: $(basename "$test") exited $status" >&2 FAILURES=$((FAILURES + 1)) fi + if grep -Eq '(^| )ERROR:|SCRIPT ERROR:|USER ERROR:' "$log"; then + echo "ERROR: unexpected Godot error output in $(basename "$test")" >&2 + FAILURES=$((FAILURES + 1)) + fi + if grep -Fq "TEST_REACHED:$(basename "$test")" "$log"; then + REACHED=$((REACHED + 1)) + else + echo "ERROR: assertion sentinel not reached in $(basename "$test")" >&2 + FAILURES=$((FAILURES + 1)) + fi + rm -f "$log" done -exit $FAILURES \ No newline at end of file +echo "TEST_ASSERTIONS_REACHED=$REACHED/${#tests[@]}" +exit $FAILURES