Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions .github/actions/setup-godot/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
13 changes: 11 additions & 2 deletions .github/actions/test/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,27 @@ 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
# renamed or deleted -- a skipped test is a green tick. This addon has a
# 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
103 changes: 47 additions & 56 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.DS_Store
dist/
__pycache__/
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions package-manifest.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
plugin.cfg
plugin.gd
plugin.gd.uid
src/telemetry_module.gd
src/telemetry_module.gd.uid
43 changes: 43 additions & 0 deletions scripts/package_addon.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions tests/fixtures/assertion_overwrite.fixture.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extends SceneTree
func _initialize() -> void:
push_error("intentional failed assertion")
quit(1)
quit(0)
4 changes: 4 additions & 0 deletions tests/fixtures/good.fixture.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
extends SceneTree
func _initialize() -> void:
print("TEST_REACHED:good.gd")
quit(0)
4 changes: 4 additions & 0 deletions tests/fixtures/hang.fixture.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
extends SceneTree
func _initialize() -> void:
while true:
await process_frame
3 changes: 3 additions & 0 deletions tests/fixtures/parse_error.fixture.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
extends SceneTree
func _initialize() -> void
quit(0)
5 changes: 5 additions & 0 deletions tests/fixtures/runtime_error_zero.fixture.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extends SceneTree
func _initialize() -> void:
push_error("intentional gate control")
print("TEST_REACHED:runtime_error_zero.gd")
quit(0)
23 changes: 23 additions & 0 deletions tests/gate_controls.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading