Repository navigation
feat: unify Windows and macOS builds (v0.3.0) #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
09674e1
feat: build and release Windows and macOS from one codebase
1window2 982162b
chore: prepare v0.3.0 release
1window2 3e1d413
test: identify the processes of the packaged app in the start and clo…
1window2 e1856f4
fix: end the program whenever its window is gone
1window2 0632bb1
test: report which files block temporary-directory cleanup on Windows
1window2 3290ed5
fix: package the Windows build as a folder instead of a single file
1window2 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
|
||
| 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])) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.