diff --git a/.github/workflows/macos-latest.yml b/.github/workflows/macos-latest.yml index 7898b87..bcd389d 100644 --- a/.github/workflows/macos-latest.yml +++ b/.github/workflows/macos-latest.yml @@ -9,11 +9,13 @@ name: Publish macos-latest (Wine 9) # `macos-latest` tag stays pinned to Wine 9 until upstream #58084 ships. # # Tags published on every run: -# - devel-YYYYMMDD-wine- (immutable, audit trail) -# - macos-latest (rolling pointer, can be overridden) +# - devel-YYYYMMDD-wine- (immutable, audit trail — always pushed) +# - (rolling pointer; only pushed when +# publish_rolling is true) # -# Run via the Actions tab → "Run workflow", optionally overriding the -# Wine version pin (e.g. to bump to a newer 9.x point release). +# Run via the Actions tab → "Run workflow". Toggle "publish_rolling" off +# to publish only the dated devel tag — useful for trial builds that +# should not move the macos-latest pointer. on: workflow_dispatch: @@ -22,9 +24,14 @@ on: description: "Debian package version pin for wine-stable (e.g. 9.0.0.0~bookworm-1)" required: true default: "9.0.0.0~bookworm-1" + publish_rolling: + description: "Also publish the rolling tag (uncheck for trial builds)" + type: boolean + required: false + default: true rolling_tag: - description: "Rolling tag to publish alongside the dated devel tag" - required: true + description: "Rolling tag name (only used when publish_rolling is checked)" + required: false default: "macos-latest" jobs: @@ -42,9 +49,24 @@ jobs: WINE_SHORT=$(echo "${{ inputs.wine_version }}" | cut -d. -f1-2) DEVEL_TAG="devel-${DATE}-wine-${WINE_SHORT}" IMAGE=aanas0sayed/docker-ltspice - echo "devel=${IMAGE}:${DEVEL_TAG}" >> "$GITHUB_OUTPUT" - echo "rolling=${IMAGE}:${{ inputs.rolling_tag }}" >> "$GITHUB_OUTPUT" - echo "Will publish: ${IMAGE}:${DEVEL_TAG} and ${IMAGE}:${{ inputs.rolling_tag }}" + # The dated devel tag is always pushed; the rolling pointer is + # only included when the publish_rolling toggle is true. The + # tag list is emitted via GITHUB_OUTPUT's heredoc form so the + # next step can pass it straight to docker/build-push-action. + { + echo "list<> "$GITHUB_OUTPUT" + + if [ "${{ inputs.publish_rolling }}" = "true" ]; then + echo "Will publish: ${IMAGE}:${DEVEL_TAG} and ${IMAGE}:${{ inputs.rolling_tag }}" + else + echo "Will publish: ${IMAGE}:${DEVEL_TAG} (no rolling tag)" + fi - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -72,15 +94,13 @@ jobs: chmod +x test.sh ./test.sh docker-ltspice:macos-ci - - name: Push image with both tags + - name: Push image uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64 push: true - tags: | - ${{ steps.tags.outputs.devel }} - ${{ steps.tags.outputs.rolling }} + tags: ${{ steps.tags.outputs.list }} build-args: | WINE_BRANCH=stable WINE_VERSION=${{ inputs.wine_version }} diff --git a/Dockerfile b/Dockerfile index 24aa499..406cc5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,21 +68,26 @@ RUN groupadd -g 1000 wineuser \ # WINEPREFIX deliberately points into /tmp (tmpfs in callers) — the # entrypoint materialises it from the on-image template on every fresh # container start. The build-time prefix lives at /opt/wineprefix-template. +# DISPLAY is intentionally NOT set here — the entrypoint uses an unset +# DISPLAY as the signal "no caller-supplied X server, start a private +# Xvfb on :99." Setting it as an image-level ENV would defeat that +# signal: the entrypoint can't distinguish image-default from +# caller-provided. ENV HOME=/home/wineuser \ WINEPREFIX=/tmp/wine-prefix \ - WINEDEBUG=-all \ - DISPLAY=:99 + WINEDEBUG=-all # ── 7. Install LTspice into a build-time prefix (as wineuser) ───────────── USER wineuser WORKDIR /home/wineuser RUN Xvfb :99 -screen 0 1024x768x24 & \ sleep 2 \ - && WINEPREFIX=/home/wineuser/.wine WINEDLLOVERRIDES="mscoree,mshtml=" \ - wineboot --init \ + && DISPLAY=:99 WINEPREFIX=/home/wineuser/.wine \ + WINEDLLOVERRIDES="mscoree,mshtml=" wineboot --init \ && WINEPREFIX=/home/wineuser/.wine wineserver --wait \ && wget -q -O /tmp/LTspice64.msi https://ltspice.analog.com/software/LTspice64.msi \ - && WINEPREFIX=/home/wineuser/.wine wine msiexec /i /tmp/LTspice64.msi /quiet /norestart \ + && DISPLAY=:99 WINEPREFIX=/home/wineuser/.wine \ + wine msiexec /i /tmp/LTspice64.msi /quiet /norestart \ && WINEPREFIX=/home/wineuser/.wine wineserver --wait \ && rm /tmp/LTspice64.msi \ && rm -rf /tmp/.wine-* /tmp/wine-* \ diff --git a/README.md b/README.md index bfad05d..26f2986 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,10 @@ docker run --rm -it \ aanas0sayed/docker-ltspice:macos-latest ``` -> [!NOTE] -> **First-run Xvfb failure:** On the first `docker run`, Xvfb may fail to start (`ERROR: Xvfb exited unexpectedly`). This is a known issue on macOS. Simply run `ltspice` from the shell prompt and it will work — the container is still usable after the entrypoint error. This only occurs with X11 forwarding. - --- ## Troubleshooting -- **Xvfb fails on first run (macOS):** The entrypoint prints `ERROR: Xvfb exited unexpectedly` on the first container start on macOS. This is a known issue — the container is still usable. Run `ltspice` from the shell and it will work normally. - **File not found / path errors:** LTspice runs inside Wine, so paths must use the Wine `Z:` drive (which maps to `/` on the container). For example, a netlist mounted at `/sim/circuit.net` should be passed as `Z:\\sim\\circuit.net`. - **Permission denied writing the log/raw file:** the container is non-root (uid 1000 by default). If your bind-mount directory isn't writable by that uid, either pass `--user=$(id -u):$(id -g)` so the in-container uid matches the directory owner, or `chmod` the directory so uid 1000 can write to it. - **Slower first call inside a container:** on every fresh `docker run`, the entrypoint copies the Wine prefix template into `/tmp/wine-prefix` (~150 MB). This is sub-second on tmpfs but adds a small fixed cost per container start. diff --git a/entrypoint.sh b/entrypoint.sh index c3c9fe7..ec62a8f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,7 +6,8 @@ # owned by the current uid (required: Wine refuses to use a prefix # not owned by the running uid). # 2. Prime Wine so first-run service init completes without an X server. -# 3. Start Xvfb on DISPLAY :99 so subsequent Wine calls have a display. +# 3. Either start a local Xvfb on :99 (headless / batch mode), or +# respect the caller's DISPLAY if X11 forwarding was set up. # 4. exec the user's command (bash by default). # # Runs as the unprivileged wineuser (uid 1000) by default. Also tolerates @@ -15,7 +16,11 @@ set -e -XVFB_DISPLAY="${DISPLAY:-:99}" +# Caller-supplied DISPLAY (e.g. `-e DISPLAY=host.docker.internal:0` for X11 +# forwarding from XQuartz). Empty if the user didn't pass one — in which +# case we run a private Xvfb on :99. +USER_DISPLAY="${DISPLAY:-}" +XVFB_DISPLAY=":99" DISPLAY_NUM="${XVFB_DISPLAY#:}" XVFB_RESOLUTION="${XVFB_RESOLUTION:-1024x768x16}" @@ -44,38 +49,44 @@ fi LTSPICE_EXE="${WINEPREFIX}/drive_c/Program Files/ADI/LTspice/LTspice.exe" -# ── 2. Prime Wine (no X server yet) ─────────────────────────────────────── +# ── 2. Prime Wine against a guaranteed-non-existent display ─────────────── # The first wine invocation after a fresh container start triggers internal # service/init work (winebth, shell32, etc.). If an X server IS available, -# those services try to create windows and block forever. Running LTspice -# once with DISPLAY pointing to a non-existent server makes them fail fast -# and complete their init. The second run then works cleanly. -echo "[entrypoint] Priming Wine (display ${XVFB_DISPLAY}, no X yet)..." -export DISPLAY="${XVFB_DISPLAY}" -wine "$LTSPICE_EXE" -b 2>/dev/null || true -wineserver --wait 2>/dev/null || true +# those services try to create windows and either block forever or finish +# in a half-initialised state that breaks the next GUI launch. Pin +# DISPLAY to :99 here — Xvfb hasn't started yet, so the connection fails +# fast and the services complete their non-GUI init cleanly. We override +# any caller-supplied DISPLAY for this single step only. +echo "[entrypoint] Priming Wine (no X yet)..." +DISPLAY=":99" wine "$LTSPICE_EXE" -b 2>/dev/null || true +DISPLAY=":99" wineserver --wait 2>/dev/null || true echo "[entrypoint] Wine primed." -# ── 3. Start Xvfb ───────────────────────────────────────────────────────── -rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}" 2>/dev/null || true -echo "[entrypoint] Starting Xvfb on ${XVFB_DISPLAY} (${XVFB_RESOLUTION})" -Xvfb "${XVFB_DISPLAY}" -screen 0 "${XVFB_RESOLUTION}" -nolisten tcp 2>/dev/null & -XVFB_PID=$! - -# Wait for the X server socket to appear (max 10 s) -for i in $(seq 1 20); do - if [ -e "/tmp/.X11-unix/X${DISPLAY_NUM}" ]; then - echo "[entrypoint] Xvfb ready (PID ${XVFB_PID})." - break - fi - if ! kill -0 "$XVFB_PID" 2>/dev/null; then - echo "[entrypoint] ERROR: Xvfb exited unexpectedly!" >&2 - break - fi - sleep 0.5 -done - -export DISPLAY="${XVFB_DISPLAY}" +# ── 3. Pick the runtime DISPLAY ─────────────────────────────────────────── +# If the caller supplied DISPLAY (X11 forwarding, e.g. host.docker.internal:0 +# from XQuartz on macOS), use it as-is and skip Xvfb. Otherwise start a +# private Xvfb on :99 for headless / batch operation. +if [ -n "$USER_DISPLAY" ]; then + echo "[entrypoint] Using caller-provided DISPLAY=${USER_DISPLAY} (skipping Xvfb)" + export DISPLAY="$USER_DISPLAY" +else + rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}" 2>/dev/null || true + echo "[entrypoint] Starting Xvfb on ${XVFB_DISPLAY} (${XVFB_RESOLUTION})" + Xvfb "${XVFB_DISPLAY}" -screen 0 "${XVFB_RESOLUTION}" -nolisten tcp 2>/dev/null & + XVFB_PID=$! + for i in $(seq 1 20); do + if [ -e "/tmp/.X11-unix/X${DISPLAY_NUM}" ]; then + echo "[entrypoint] Xvfb ready (PID ${XVFB_PID})." + break + fi + if ! kill -0 "$XVFB_PID" 2>/dev/null; then + echo "[entrypoint] ERROR: Xvfb exited unexpectedly!" >&2 + break + fi + sleep 0.5 + done + export DISPLAY="${XVFB_DISPLAY}" +fi # ── 4. Hand off to the user's command ────────────────────────────────────── exec "$@"