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
161 changes: 161 additions & 0 deletions .github/scripts/smoke_frozen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Start the packaged app, check that it stays up without multiplying, then close it.

Used by CI. On Windows the window is asked to close the way its close button does; on
macOS the window process is killed outright, like a crash. Either way every process has
to be gone shortly afterwards: the program must never keep running without a window.
"""

import json
import os
import signal
import subprocess
import sys
import tempfile
import time

STARTUP_SECONDS = 25
SHUTDOWN_SECONDS = 30
# The proxy, the window process and their launcher/helper processes. A frozen build that
# re-launches itself during multiprocessing start-up blows far past this.
MAX_PROCESSES = 8
WINDOWS = sys.platform == "win32"
WINDOW_PROCESS_MARK = "--multiprocessing-fork"


def processes(executable: str) -> list[dict]:
"""Return pid, parent pid and command line of every running app process."""
if WINDOWS:
listing = subprocess.run(
[
"powershell",
"-NoProfile",
"-Command",
"Get-CimInstance Win32_Process -Filter \"name='gfl2logger.exe'\" | "
"Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json",
],
capture_output=True,
text=True,
).stdout.strip()
if not listing:
return []
found = json.loads(listing)
found = [found] if isinstance(found, dict) else found
return [
{
"pid": item["ProcessId"],
"parent": item["ParentProcessId"],
"command": item["CommandLine"] or "",
}
for item in found
]

listing = subprocess.run(
["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True
).stdout
found = []
for line in listing.splitlines():
pid, parent, command = line.split(None, 2)
# Match the program itself, not tools that merely mention its path.
if command.startswith(executable):
found.append({"pid": int(pid), "parent": int(parent), "command": command})
return found


def describe(found: list[dict]) -> None:
for item in found:
print(f" pid={item['pid']} parent={item['parent']} {item['command']}")


def window_processes(found: list[dict]) -> list[dict]:
# The window lives in the multiprocessing child.
return [item for item in found if WINDOW_PROCESS_MARK in item["command"]]


def force_stop(process: subprocess.Popen, executable: str) -> None:
if WINDOWS:
subprocess.run(
["taskkill", "/F", "/T", "/IM", "gfl2logger.exe"], capture_output=True
)
else:
for item in processes(executable):
try:
os.kill(item["pid"], signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=30)


def close_window(found: list[dict]) -> None:
for item in window_processes(found):
if WINDOWS:
# Without /F, taskkill posts WM_CLOSE, which is what the close button does.
result = subprocess.run(
["taskkill", "/PID", str(item["pid"])], capture_output=True, text=True
)
print(f"close request to pid={item['pid']}: {result.stdout.strip()}")
else:
os.kill(item["pid"], signal.SIGKILL)
print(f"killed window process pid={item['pid']}")


def diagnose_windows() -> None:
report = subprocess.run(
[
"powershell",
"-NoProfile",
"-Command",
"Get-Process gfl2logger -ErrorAction SilentlyContinue | "
"Select-Object Id,MainWindowTitle,Responding,StartTime | Format-List; "
"Get-CimInstance Win32_Process | "
"Where-Object { $_.Name -match 'redirector|divert' } | "
"Select-Object ProcessId,ParentProcessId,Name,ExecutablePath | Format-List",
],
capture_output=True,
text=True,
)
print(report.stdout.strip() or report.stderr.strip())


def main(executable: str) -> int:
executable = os.path.abspath(executable)
# Windows builds write exports and settings to the working directory.
workdir = tempfile.mkdtemp(prefix="gfl2logger-smoke-")
process = subprocess.Popen([executable], cwd=workdir)
Comment thread
1window2 marked this conversation as resolved.
Resolved

time.sleep(STARTUP_SECONDS)
started = processes(executable)
print(f"processes after {STARTUP_SECONDS}s: {len(started)}")
describe(started)
if process.poll() is not None:
print(f"FAIL: exited during start-up with code {process.returncode}")
return 1
if not 2 <= len(started) <= MAX_PROCESSES:
print(f"FAIL: expected 2..{MAX_PROCESSES} processes")
force_stop(process, executable)
return 1
if not window_processes(started):
print("FAIL: the window process is not running")
force_stop(process, executable)
return 1

close_window(started)
closed_at = time.monotonic()
deadline = closed_at + SHUTDOWN_SECONDS
while time.monotonic() < deadline and processes(executable):
time.sleep(0.5)
remaining = processes(executable)
if remaining:
print(f"FAIL: {len(remaining)} processes still running after closing")
describe(remaining)
if WINDOWS:
diagnose_windows()
force_stop(process, executable)
return 1

elapsed = time.monotonic() - closed_at
print(f"OK: window shown, and every process was gone {elapsed:.1f}s after closing")
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
72 changes: 72 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: build

# Reusable build: runs the tests and packages the app on every supported platform.
on:
workflow_call:
inputs:
version-label:
description: Text placed in artifact file names, such as a release tag.
required: true
type: string

permissions:
contents: read

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
platform: windows-x64
- os: macos-15
platform: macos-arm64
runs-on: ${{ matrix.os }}
env:
VERSION_LABEL: ${{ inputs.version-label }}
steps:
- uses: actions/checkout@v4
- uses: pdm-project/setup-pdm@v4
with:
cache: true
- run: pdm install
# The tests import the generated protobuf modules, which are not checked in.
- run: pdm run protoc
- name: Run tests
run: pdm run python -m unittest discover -s tests -v
env:
# Hosted runners have a desktop session, so the real window can be exercised.
GFL2LOGGER_GUI_TESTS: "1"
- run: pdm run pyinstaller
- name: Start and close the packaged app
shell: bash
run: |
if [ "$RUNNER_OS" = "Windows" ]; then
pdm run python .github/scripts/smoke_frozen.py dist/gfl2logger/gfl2logger.exe
else
pdm run python .github/scripts/smoke_frozen.py dist/gfl2logger.app/Contents/MacOS/gfl2logger
fi
- name: Package Windows artifact
if: runner.os == 'Windows'
shell: bash
run: |
test -e dist/gfl2logger/_internal/mitmproxy_windows/WinDivert64.sys
mkdir -p release
(cd dist && pdm run python -m zipfile -c "../release/gfl2logger-${VERSION_LABEL}-windows-x64.zip" gfl2logger)
- name: Package macOS artifacts
if: runner.os == 'macOS'
run: |
test -e "dist/gfl2logger.app/Contents/Resources/mitmproxy_macos/Mitmproxy Redirector.app.tar"
codesign --verify --deep --strict dist/gfl2logger.app
mkdir -p release dist/dmg-root
(cd dist && COPYFILE_DISABLE=1 zip -qry "../release/gfl2logger-${VERSION_LABEL}-macos-arm64.zip" gfl2logger.app -x '*.DS_Store' -x '__MACOSX/*')
cp -R dist/gfl2logger.app dist/dmg-root/
ln -s /Applications dist/dmg-root/Applications
hdiutil create -volname gfl2logger -srcfolder dist/dmg-root -ov -format UDZO "release/gfl2logger-${VERSION_LABEL}-macos-arm64.dmg"
- uses: actions/upload-artifact@v4
with:
name: gfl2logger-${{ matrix.platform }}
path: release/*
if-no-files-found: error
retention-days: 14
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: ci

on:
pull_request:
push:
branches:
- master
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
uses: ./.github/workflows/build.yml
with:
version-label: ci
56 changes: 33 additions & 23 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,47 @@ on:
- "v*.*.*"

permissions:
contents: write
contents: read

jobs:
release-macos:
runs-on: macos-15
verify-version:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pdm-project/setup-pdm@v4
# The app reports the version from version.py, so it has to match the tag.
- run: |
tag="${GITHUB_REF_NAME}"
grep -qx "VERSION = \"${tag#v}\"" gfl2logger/utils/version.py

build:
needs: verify-version
uses: ./.github/workflows/build.yml
with:
version-label: ${{ github.ref_name }}

publish:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
cache: true
- run: pdm install
# The tests import the generated protobuf modules, which are not checked in.
- run: pdm run protoc
- run: pdm run python -m unittest discover -s tests -v
- run: pdm run pyinstaller
- name: Package macOS artifacts
run: |
cd dist
COPYFILE_DISABLE=1 zip -qry "gf2logger-${GITHUB_REF_NAME}-macos-arm64.zip" gf2logger.app -x '*.DS_Store' -x '__MACOSX/*'
cd ..
mkdir -p dist/dmg-root
cp -R dist/gf2logger.app dist/dmg-root/
ln -s /Applications dist/dmg-root/Applications
hdiutil create -volname gf2logger -srcfolder dist/dmg-root -ov -format UDZO "dist/gf2logger-${GITHUB_REF_NAME}-macos-arm64.dmg"
path: release
merge-multiple: true
# RELEASE_NOTES.md links to the downloads relative to the repository so the file
# works in any fork; a release body needs absolute links.
- name: Point release note links at this repository
run: sed "s#](\.\./\.\./#](https://github.com/${GITHUB_REPOSITORY}/#g" RELEASE_NOTES.md > release-body.md
- uses: softprops/action-gh-release@v2
with:
name: gfl2logger for macOS ${{ github.ref_name }}
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
body_path: RELEASE_NOTES.md
body_path: release-body.md
files: |
dist/gf2logger-${{ github.ref_name }}-macos-arm64.zip
dist/gf2logger-${{ github.ref_name }}-macos-arm64.dmg
release/gfl2logger-${{ github.ref_name }}-windows-x64.zip
release/gfl2logger-${{ github.ref_name }}-macos-arm64.zip
release/gfl2logger-${{ github.ref_name }}-macos-arm64.dmg
overwrite_files: false
fail_on_unmatched_files: true
Loading
Loading