Skip to content

Fix Windows installer stamping itself as beta.3 #22

Fix Windows installer stamping itself as beta.3

Fix Windows installer stamping itself as beta.3 #22

name: Build installers
# Builds the three installer artifacts (Windows .exe, macOS .zip, Linux
# .tar.gz) plus SHA256SUMS, then smoke-tests each one on its own OS's GitHub
# runner. These are automated platform tests, not a substitute for a human
# running the installer on their own machine -- see README.md's Testing
# status section for the exact, honest label for each platform.
#
# Every smoke test treats an unexpected non-zero exit from the installer
# script as a real failure -- no `|| true`, no post-extraction `chmod`
# repair. macOS/Linux get a harmless fake `claude` stub on PATH (Chrome is
# already genuinely present on both runner images) so a clean run produces
# an unambiguous, fully-successful result, not one that's silently masked
# by a "Claude Code missing" partial failure. The Windows job additionally
# proves the installed venv came from uv's own *managed* Python 3.12, not
# the runner's pre-installed system Python -- see that job's smoke-test
# steps for exactly how.
#
# Runs on: a manual trigger, an `installer-v*` tag push, or -- so installer
# changes are genuinely built and smoke-tested before merge, not just
# reviewed as text -- any pull request that touches installer files. It
# never runs on every push to main, and it never publishes a GitHub release
# by itself; that stays a manual step (see RELEASE_CHECKLIST.md).
on:
workflow_dispatch: {}
push:
tags:
- "installer-v*"
pull_request:
paths:
- "installer/**"
- ".github/workflows/build-installers.yml"
- "requirements.txt"
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Stage repo payload (excluding profiles/, .git, dev artifacts)
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path payload | Out-Null
$exclude = @('.git', 'profiles', '.venv', '__pycache__', '.pytest_cache', 'dist', 'payload', 'payload-uv', 'exported-docs')
Get-ChildItem -Force -Path . | Where-Object { $exclude -notcontains $_.Name } | ForEach-Object {
Copy-Item -Recurse -Force $_.FullName -Destination "payload/$($_.Name)"
}
New-Item -ItemType Directory -Force -Path payload/profiles | Out-Null
# uv replaces the Windows embeddable-Python approach: the embeddable
# distribution ships without ensurepip, so `venv.EnvBuilder(with_pip=True)`
# cannot bootstrap pip into a fresh venv created from it. uv manages its
# own Python provisioning and has no ensurepip dependency -- see
# installer/bootstrap.py's module docstring for the full reasoning.
#
# Pinned to a specific uv release rather than "latest" so a build is
# reproducible; bump this deliberately (and re-run the smoke test below)
# when updating, not silently. setup.iss's `--python-preference
# only-managed` flag is verified against this exact version's real
# PythonPreference enum -- see setup.iss's [Run] comment.
- name: Install pinned uv
uses: astral-sh/setup-uv@v3
with:
version: "0.5.11"
- name: Stage uv for bundling
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path payload-uv | Out-Null
$uvPath = (Get-Command uv).Source
Copy-Item -Force $uvPath payload-uv/uv.exe
# Runs the exact `uv venv --python 3.12 --python-preference only-managed`
# command the packaged installer uses, but directly -- not wrapped in
# Inno Setup's hidden [Run] entry -- with full output streamed to this
# log and a hard timeout. This isolates whether uv's managed-Python
# download itself is slow/network-bound (expected to show real
# progress here) from whether something about Inno Setup's Exec
# mechanism is the problem (e.g. an unread output pipe filling up and
# blocking the child process -- a known class of issue when a parent
# launches a subprocess without draining its stdout/stderr).
- name: Diagnostic -- uv managed-Python provisioning, run directly
shell: pwsh
timeout-minutes: 6
run: |
$diagVenv = "$env:RUNNER_TEMP\uv-diagnostic-venv"
Write-Host "Running: uv venv --python 3.12 --python-preference only-managed $diagVenv"
& payload-uv\uv.exe venv --python 3.12 --python-preference only-managed $diagVenv
if ($LASTEXITCODE -ne 0) { throw "uv venv failed with exit code $LASTEXITCODE" }
& "$diagVenv\Scripts\python.exe" --version
Write-Host "OK: uv provisioned a managed Python directly, outside Inno Setup, without hanging."
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
- name: Build Socials-Studio-Setup.exe
shell: cmd
timeout-minutes: 5
run: |
"C:\Program Files (x86)\Inno Setup 6\iscc.exe" installer\windows\setup.iss
- name: Checksum
shell: pwsh
run: |
$hash = Get-FileHash -Algorithm SHA256 dist\Socials-Studio-Setup.exe
"$($hash.Hash.ToLower()) Socials-Studio-Setup.exe" | Out-File -Encoding ascii dist\Socials-Studio-Setup.exe.sha256
# A second, separate installer build -- never uploaded as an artifact,
# never shipped -- whose bundled requirements.txt names a package that
# cannot exist, so `uv pip install` genuinely fails during a real
# silent install. Used only by "Smoke test -- dependency-install
# failure stops setup cleanly" below, to prove setup.iss's
# RunPythonSetup/PythonSetupFailed error handling actually works, not
# just that it compiles. OutputBaseFilename is overridden via /D so
# this never touches dist\Socials-Studio-Setup.exe or its checksum,
# both already produced above.
- name: Build failure-path test installer (deliberately-broken requirements.txt)
shell: pwsh
timeout-minutes: 5
run: |
Copy-Item payload\requirements.txt payload\requirements.txt.orig
"this-package-does-not-exist-ss-failure-path-test==0.0.0" | Set-Content -Encoding ascii payload\requirements.txt
& "C:\Program Files (x86)\Inno Setup 6\iscc.exe" /DMyOutputBaseFilename=Socials-Studio-Setup-DepsFailTest installer\windows\setup.iss
if ($LASTEXITCODE -ne 0) { throw "iscc failed to build the failure-path test installer (exit code $LASTEXITCODE)" }
Move-Item -Force payload\requirements.txt.orig payload\requirements.txt
- name: Record the runner's own system Python, for comparison
shell: pwsh
run: |
$systemPython = (Get-Command python -ErrorAction SilentlyContinue).Source
Write-Host "Runner's own system python (if any): $systemPython"
if ($systemPython) {
echo "SYSTEM_PYTHON=$systemPython" >> $env:GITHUB_ENV
}
- name: Smoke test -- silent install
shell: pwsh
timeout-minutes: 6
run: |
$installDir = "$env:RUNNER_TEMP\ss-install"
$proc = Start-Process -FilePath "dist\Socials-Studio-Setup.exe" `
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/DIR=`"$installDir`"" `
-PassThru
# Poll ourselves (budget shorter than this step's own timeout-minutes)
# rather than a bare -Wait, so that if the install hangs we can dump
# diagnostics -- what's still running, and however much of
# _setup-python.log got written -- before killing it, all inside this
# same step. A step killed purely by its own timeout-minutes loses
# that chance: the script is torn down mid-line with no opportunity
# to inspect the state it leaves behind.
$deadline = (Get-Date).AddMinutes(4)
while (-not $proc.HasExited -and (Get-Date) -lt $deadline) {
Start-Sleep -Seconds 5
}
if (-not $proc.HasExited) {
Write-Host "Socials-Studio-Setup.exe did not finish within the polling budget -- still running."
Write-Host "----- processes matching setup/cmd/uv/python under $installDir or dist -----"
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -match [regex]::Escape($installDir) -or $_.CommandLine -match "Socials-Studio-Setup" } |
Select-Object ProcessId, Name, CommandLine | Format-List | Out-String | Write-Host
$log = "$installDir\_setup-python.log"
if (Test-Path $log) {
Write-Host "----- $log (partial, install still running) -----"
Get-Content $log | Write-Host
Write-Host "----------------"
} else {
Write-Host "$log does not exist yet."
}
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
throw "Smoke test failed: silent install did not complete within the polling budget."
}
$checks = @(
"$installDir\README.md",
"$installDir\.venv\Scripts\python.exe",
"$installDir\launch.bat",
"$installDir\.first-run-pending"
)
$failed = $false
foreach ($path in $checks) {
if (Test-Path $path) {
Write-Host "OK: $path exists"
} else {
Write-Host "MISSING: $path"
$failed = $true
}
}
if ($failed) {
$log = "$installDir\_setup-python.log"
if (Test-Path $log) {
Write-Host "----- $log -----"
Get-Content $log | Write-Host
Write-Host "----------------"
} else {
Write-Host "$log does not exist either -- the uv [Run] steps may not have executed at all."
}
throw "Smoke test failed: one or more expected files are missing after silent install."
}
- name: Smoke test -- prove the venv is a uv-*managed* Python 3.12, not the runner's own
shell: pwsh
timeout-minutes: 3
run: |
$installDir = "$env:RUNNER_TEMP\ss-install"
$pyvenvCfgPath = "$installDir\.venv\pyvenv.cfg"
if (-not (Test-Path $pyvenvCfgPath)) { throw "pyvenv.cfg not found -- venv was not created as expected." }
$pyvenvCfg = Get-Content $pyvenvCfgPath -Raw
Write-Host "----- .venv\pyvenv.cfg -----"
Write-Host $pyvenvCfg
Write-Host "----------------------------"
# GitHub-hosted Windows runners keep every pre-installed tool version
# (including Python) under a well-known, documented path containing
# "hostedtoolcache". If the venv's own config mentions it, uv used the
# runner's pre-installed Python instead of provisioning a managed one.
if ($pyvenvCfg -match '(?i)hostedtoolcache') {
throw "venv's pyvenv.cfg references the runner's own hostedtoolcache Python -- uv used the runner's system Python instead of a managed one."
}
if ($env:SYSTEM_PYTHON -and ($pyvenvCfg.ToLower().Contains($env:SYSTEM_PYTHON.ToLower()))) {
throw "venv's pyvenv.cfg matches the runner's own system python path ($env:SYSTEM_PYTHON) -- uv did not provision a managed Python."
}
Write-Host "OK: pyvenv.cfg does not reference the runner's hostedtoolcache or system python -- this venv came from a uv-managed Python."
$venvPythonVersion = & "$installDir\.venv\Scripts\python.exe" --version
Write-Host "venv python reports: $venvPythonVersion"
if ($venvPythonVersion -notmatch '^Python 3\.12\.') {
throw "Expected the managed venv python to report Python 3.12.x, got: $venvPythonVersion"
}
$importCheck = & "$installDir\.venv\Scripts\python.exe" -c "import playwright.sync_api; print('playwright import OK')"
Write-Host $importCheck
if ($importCheck -notmatch 'playwright import OK') {
throw "Dependency import check failed: $importCheck"
}
Write-Host "OK: venv uses a uv-managed Python 3.12 (not the runner's), and playwright imports successfully."
- name: Smoke test -- reinstall preserves profiles/ and the venv still works
shell: pwsh
timeout-minutes: 8
run: |
$installDir = "$env:RUNNER_TEMP\ss-install"
$profileFile = "$installDir\profiles\fake-session\storage_state.json"
New-Item -ItemType Directory -Force -Path (Split-Path $profileFile) | Out-Null
$originalContent = '{"cookies": "fake-not-real-session-data"}'
Set-Content -Path $profileFile -Value $originalContent -NoNewline
Start-Process -FilePath "dist\Socials-Studio-Setup.exe" `
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/DIR=`"$installDir`"" `
-Wait -PassThru | Out-Null
$afterContent = Get-Content -Path $profileFile -Raw
if ($afterContent.Trim() -ne $originalContent.Trim()) {
throw "Smoke test failed: profiles/ file changed after reinstall. Before: $originalContent After: $afterContent"
}
Write-Host "OK: profiles/fake-session/storage_state.json unchanged after reinstall"
$venvPythonVersionAfter = & "$installDir\.venv\Scripts\python.exe" --version
if ($venvPythonVersionAfter -notmatch '^Python 3\.12\.') {
throw "After reinstall, expected Python 3.12.x, got: $venvPythonVersionAfter"
}
$importCheckAfter = & "$installDir\.venv\Scripts\python.exe" -c "import playwright.sync_api; print('playwright import OK')"
if ($importCheckAfter -notmatch 'playwright import OK') {
throw "After reinstall, dependency import check failed: $importCheckAfter"
}
Write-Host "OK: venv still reports Python 3.12 and playwright still imports after reinstall."
# Uses the deliberately-broken installer built above (bad
# requirements.txt) to prove setup.iss's RunPythonSetup/
# PythonSetupFailed handling genuinely stops setup on a real
# dependency-install failure -- not just that setup-python.bat records
# a non-zero exit in its own log, but that Setup itself reports
# failure, never runs bootstrap.py, never writes the first-run
# marker, and never touches profiles/. Does not touch or weaken the
# successful-path smoke tests above -- entirely separate install dir,
# entirely separate installer binary.
- name: Smoke test -- dependency-install failure stops setup cleanly
shell: pwsh
timeout-minutes: 6
run: |
$installDir = "$env:RUNNER_TEMP\ss-install-fail"
$profileFile = "$installDir\profiles\fake-session\storage_state.json"
New-Item -ItemType Directory -Force -Path (Split-Path $profileFile) | Out-Null
$originalContent = '{"cookies": "fake-not-real-session-data"}'
Set-Content -Path $profileFile -Value $originalContent -NoNewline
$proc = Start-Process -FilePath "dist\Socials-Studio-Setup-DepsFailTest.exe" `
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/DIR=`"$installDir`"" `
-PassThru
# Same self-polling pattern as the main silent-install smoke test
# above -- if RaiseException somehow doesn't stop Setup promptly,
# this fails within its own bounded budget with diagnostics,
# rather than either hanging for the whole job or silently passing.
$deadline = (Get-Date).AddMinutes(4)
while (-not $proc.HasExited -and (Get-Date) -lt $deadline) {
Start-Sleep -Seconds 5
}
if (-not $proc.HasExited) {
Write-Host "Socials-Studio-Setup-DepsFailTest.exe did not finish within the polling budget -- still running."
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
throw "Failure-path smoke test itself hung waiting for the installer to exit."
}
Write-Host "Installer exit code: $($proc.ExitCode)"
$log = "$installDir\_setup-python.log"
if (Test-Path $log) {
Write-Host "----- $log -----"
Get-Content $log | Write-Host
Write-Host "----------------"
} else {
Write-Host "$log does not exist -- setup-python.bat may not have run at all."
}
$failed = $false
if ($proc.ExitCode -eq 0) {
Write-Host "FAIL: installer reported success (exit code 0) despite the broken requirements.txt."
$failed = $true
} else {
Write-Host "OK: installer reported failure (exit code $($proc.ExitCode)), not success."
}
if ((Test-Path $log) -and (Select-String -Path $log -Pattern "uv pip install exit=0" -Quiet)) {
Write-Host "FAIL: $log shows uv pip install exit=0, but it should have failed."
$failed = $true
} else {
Write-Host "OK: setup-python.bat's own log does not show a successful uv pip install."
}
if (Test-Path "$installDir\.first-run-pending") {
Write-Host "FAIL: .first-run-pending exists -- bootstrap.py ran despite the failed Python setup."
$failed = $true
} else {
Write-Host "OK: .first-run-pending is absent -- bootstrap.py correctly never ran."
}
$afterContent = Get-Content -Path $profileFile -Raw
if ($afterContent.Trim() -ne $originalContent.Trim()) {
Write-Host "FAIL: profiles/fake-session/storage_state.json changed during the failed install. Before: $originalContent After: $afterContent"
$failed = $true
} else {
Write-Host "OK: profiles/fake-session/storage_state.json unchanged after the failed install."
}
if ($failed) {
throw "Dependency-install failure-path smoke test failed -- see FAIL lines above."
}
- uses: actions/upload-artifact@v4
with:
name: windows-installer
path: |
dist/Socials-Studio-Setup.exe
dist/Socials-Studio-Setup.exe.sha256
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Package source + installer scripts
run: |
mkdir -p dist
zip -r dist/Socials-Studio-macOS.zip . \
-x ".git/*" -x "profiles/*" -x ".venv/*" -x "__pycache__/*" \
-x ".pytest_cache/*" -x "dist/*" -x "exported-docs/*"
- name: Checksum
run: |
shasum -a 256 dist/Socials-Studio-macOS.zip > dist/Socials-Studio-macOS.zip.sha256
# Chrome is already genuinely present on macos-latest -- only Claude
# Code is missing (correctly; CI must never install the real thing).
# A harmless stub gets an unambiguous, fully-successful smoke-test
# result instead of one partially masked by "Claude Code missing".
- name: Add a fake claude stub to PATH for an unambiguous result
run: |
mkdir -p "$RUNNER_TEMP/fake-bin"
cat > "$RUNNER_TEMP/fake-bin/claude" <<'EOF'
#!/usr/bin/env bash
echo "claude 0.0.0-fake (smoke-test stub, not real Claude Code)"
EOF
chmod +x "$RUNNER_TEMP/fake-bin/claude"
echo "$RUNNER_TEMP/fake-bin" >> "$GITHUB_PATH"
- name: Smoke test -- extract and install
run: |
set -euo pipefail
WORKDIR="$RUNNER_TEMP/ss-smoke"
INSTALL_DIR="$RUNNER_TEMP/ss-install"
mkdir -p "$WORKDIR"
unzip -q dist/Socials-Studio-macOS.zip -d "$WORKDIR"
# The archive must contain executable scripts AS PACKAGED -- no
# repair here. If this fails, archive creation or extraction lost
# the executable bit; that's a real bug, not something to paper
# over inside the smoke test.
test -x "$WORKDIR/installer/macos/install.sh"
test -x "$WORKDIR/installer/macos/SocialsStudio.command"
echo "OK: install.sh and SocialsStudio.command are executable as extracted"
"$WORKDIR/installer/macos/install.sh" "$INSTALL_DIR"
fail=0
for f in "$INSTALL_DIR/README.md" "$INSTALL_DIR/.venv/bin/python3" \
"$INSTALL_DIR/SocialsStudio.command" "$INSTALL_DIR/.first-run-pending"; do
if [ -e "$f" ]; then
echo "OK: $f exists"
else
echo "MISSING: $f"
fail=1
fi
done
[ "$fail" -eq 0 ] || { echo "Smoke test failed: one or more expected files are missing."; exit 1; }
"$INSTALL_DIR/.venv/bin/python3" -c "import playwright.sync_api; print('playwright import OK')"
- name: Smoke test -- reinstall preserves profiles/
run: |
set -euo pipefail
WORKDIR="$RUNNER_TEMP/ss-smoke"
INSTALL_DIR="$RUNNER_TEMP/ss-install"
PROFILE_FILE="$INSTALL_DIR/profiles/fake-session/storage_state.json"
mkdir -p "$(dirname "$PROFILE_FILE")"
echo -n '{"cookies": "fake-not-real-session-data"}' > "$PROFILE_FILE"
"$WORKDIR/installer/macos/install.sh" "$INSTALL_DIR"
if ! diff -q <(echo -n '{"cookies": "fake-not-real-session-data"}') "$PROFILE_FILE" >/dev/null; then
echo "Smoke test failed: profiles/ file changed after reinstall."
exit 1
fi
echo "OK: profiles/fake-session/storage_state.json unchanged after reinstall"
"$INSTALL_DIR/.venv/bin/python3" -c "import playwright.sync_api; print('playwright import OK')"
- uses: actions/upload-artifact@v4
with:
name: macos-installer
path: |
dist/Socials-Studio-macOS.zip
dist/Socials-Studio-macOS.zip.sha256
linux:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Ensure venv/rsync are present
run: |
sudo apt-get update -y
sudo apt-get install -y python3-venv python3-pip rsync
- name: Package source + installer scripts
run: |
mkdir -p dist
tar --exclude='.git' --exclude='profiles' --exclude='.venv' \
--exclude='__pycache__' --exclude='.pytest_cache' \
--exclude='dist' --exclude='exported-docs' \
-czf dist/Socials-Studio-Linux.tar.gz .
- name: Checksum
run: |
sha256sum dist/Socials-Studio-Linux.tar.gz > dist/Socials-Studio-Linux.tar.gz.sha256
# Chrome is already genuinely present on ubuntu-24.04 -- only Claude
# Code is missing (correctly; CI must never install the real thing).
# A harmless stub gets an unambiguous, fully-successful smoke-test
# result instead of one partially masked by "Claude Code missing".
- name: Add a fake claude stub to PATH for an unambiguous result
run: |
mkdir -p "$RUNNER_TEMP/fake-bin"
cat > "$RUNNER_TEMP/fake-bin/claude" <<'EOF'
#!/usr/bin/env bash
echo "claude 0.0.0-fake (smoke-test stub, not real Claude Code)"
EOF
chmod +x "$RUNNER_TEMP/fake-bin/claude"
echo "$RUNNER_TEMP/fake-bin" >> "$GITHUB_PATH"
- name: Smoke test -- extract and install
run: |
set -euo pipefail
WORKDIR="$RUNNER_TEMP/ss-smoke"
INSTALL_DIR="$RUNNER_TEMP/ss-install"
mkdir -p "$WORKDIR"
tar -xzf dist/Socials-Studio-Linux.tar.gz -C "$WORKDIR"
# The archive must contain executable scripts AS PACKAGED -- no
# repair here. If this fails, archive creation or extraction lost
# the executable bit; that's a real bug, not something to paper
# over inside the smoke test.
test -x "$WORKDIR/installer/linux/install.sh"
test -x "$WORKDIR/installer/linux/socials-studio-launch.sh"
echo "OK: install.sh and socials-studio-launch.sh are executable as extracted"
"$WORKDIR/installer/linux/install.sh" "$INSTALL_DIR"
fail=0
for f in "$INSTALL_DIR/README.md" "$INSTALL_DIR/.venv/bin/python3" \
"$INSTALL_DIR/socials-studio-launch.sh" "$INSTALL_DIR/.first-run-pending"; do
if [ -e "$f" ]; then
echo "OK: $f exists"
else
echo "MISSING: $f"
fail=1
fi
done
[ -f "$HOME/.local/share/applications/socials-studio.desktop" ] \
&& echo "OK: desktop entry installed" \
|| { echo "MISSING: desktop entry"; fail=1; }
[ "$fail" -eq 0 ] || { echo "Smoke test failed: one or more expected files are missing."; exit 1; }
"$INSTALL_DIR/.venv/bin/python3" -c "import playwright.sync_api; print('playwright import OK')"
- name: Smoke test -- reinstall preserves profiles/
run: |
set -euo pipefail
WORKDIR="$RUNNER_TEMP/ss-smoke"
INSTALL_DIR="$RUNNER_TEMP/ss-install"
PROFILE_FILE="$INSTALL_DIR/profiles/fake-session/storage_state.json"
mkdir -p "$(dirname "$PROFILE_FILE")"
echo -n '{"cookies": "fake-not-real-session-data"}' > "$PROFILE_FILE"
"$WORKDIR/installer/linux/install.sh" "$INSTALL_DIR"
if ! diff -q <(echo -n '{"cookies": "fake-not-real-session-data"}') "$PROFILE_FILE" >/dev/null; then
echo "Smoke test failed: profiles/ file changed after reinstall."
exit 1
fi
echo "OK: profiles/fake-session/storage_state.json unchanged after reinstall"
"$INSTALL_DIR/.venv/bin/python3" -c "import playwright.sync_api; print('playwright import OK')"
- uses: actions/upload-artifact@v4
with:
name: linux-installer
path: |
dist/Socials-Studio-Linux.tar.gz
dist/Socials-Studio-Linux.tar.gz.sha256