From 7e2876cf50695e1abe84a3569b2d01ddb62b094c Mon Sep 17 00:00:00 2001 From: Marc Beinder Date: Fri, 6 Nov 2026 14:49:21 -0600 Subject: [PATCH 1/5] ci(workflows): matrixes everwhere! --- .github/workflows/ci-build.yml | 397 +++---------------------------- .github/workflows/ci-linux.yml | 148 ++++++++++++ .github/workflows/ci-macos.yml | 118 +++++++++ .github/workflows/ci-windows.yml | 129 ++++++++++ linux/validate-package.sh | 60 +++++ 5 files changed, 490 insertions(+), 362 deletions(-) create mode 100644 .github/workflows/ci-linux.yml create mode 100644 .github/workflows/ci-macos.yml create mode 100644 .github/workflows/ci-windows.yml create mode 100644 linux/validate-package.sh diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index c9732cf..78e7b00 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -1,17 +1,4 @@ -# Runs the Go tests, and builds the macOS disk image (signed and notarized) and the Windows installer -# (signed with Artifact Signing) and keeps them as run artifacts. Signing takes the secrets and -# variables of the "release" environment, and is required in the merge queue and for releases. -# -# - on every pull request: application Go tests, tool tests when tools change, and the apps compiled -# and tested unsigned ("macOS app", "Windows app"); the signed builds are skipped (which counts -# as passed). One that changes only docs/ skips the Go tests too, unless it goes into main, and one -# that changes docs/ gets the docs check (tools/docslint) -# - in the merge queue: everything, named with the version in .release-please-manifest.json. These -# are the checks that must pass before a PR (the release PR) is merged, and release-stable.yml attaches -# these very files to the release. -# - by hand from the Actions tab (any branch): unsigned, named dev- -# - from release-stable.yml, only when it cannot find the merge queue's builds -# - from release-beta.yml, for a beta (named with its version, e.g. 1.1.0-beta.2) +# Runs shared checks and delegates platform work while keeping one build run for release artifacts. name: Build on: @@ -25,7 +12,7 @@ on: type: string default: "" version: - description: Version to stamp into the builds (e.g. 1.2.0). + description: Version to stamp into the builds (for example, 1.2.0). type: string default: "" @@ -33,7 +20,6 @@ permissions: contents: read concurrency: - # A newer push to a PR replaces the tests of the older one. group: build-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true @@ -57,7 +43,6 @@ jobs: if [ -n "$INPUT_VERSION" ]; then version="$INPUT_VERSION" elif [ "$EVENT" = merge_group ]; then - # The release PR's new version; any other PR keeps the last released one. version=$(jq -r '."." // empty' .release-please-manifest.json) version="${version:-dev-$(git rev-parse --short HEAD)}" else @@ -68,9 +53,6 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - # What a pull request changes: "code" unless it's only docs/, "docs" if any of docs/. Into main - # it's always "code", so everything that's released has had the Go tests run on it. - # (With git, not the API: release-beta.yml and release-stable.yml call this workflow with contents: read only.) changes: runs-on: ubuntu-latest outputs: @@ -91,13 +73,13 @@ jobs: BASE: ${{ github.event.pull_request.base.ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + #language=bash run: | if [ "$EVENT" != pull_request ]; then echo "code=true" >> "$GITHUB_OUTPUT" echo "tooling=true" >> "$GITHUB_OUTPUT" exit 0 fi - # Since the PR's branch left its base; --no-renames lists both sides of a rename. files=$(git diff --name-only --no-renames "$BASE_SHA...$HEAD_SHA") code=false if [ "$BASE" = main ] || grep -v '^docs/' <<< "$files" | grep -q .; then code=true; fi @@ -109,7 +91,6 @@ jobs: echo "docs=$docs" >> "$GITHUB_OUTPUT" echo "tooling=$tooling" >> "$GITHUB_OUTPUT" - # The pages, navigation.json, links and images in docs/, as flockphp.com reads them. docs: name: Docs needs: changes @@ -119,7 +100,6 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/setup-go@v7 with: go-version-file: go.mod @@ -127,9 +107,6 @@ jobs: - name: Check run: go run ./tools/docslint - # The application Go tests on both platforms Flock supports (the race detector needs cgo, so macOS only). - # Without code changes the steps are skipped, not the job: a skipped matrix job reports under its - # unexpanded name, so the required "Go tests (...)" checks would never pass. go-test: name: Go tests (${{ matrix.name }}) needs: [ version, changes ] @@ -137,10 +114,18 @@ jobs: fail-fast: false matrix: include: - - { name: macOS, os: macos-26, flags: -race } - - { name: Windows, os: windows-2025, flags: "" } - - { name: Linux, os: ubuntu-24.04, flags: -race } - - { name: Linux arm64, os: ubuntu-24.04-arm, flags: -race } + - name: macOS + os: macos-26 + flags: -race + - name: Windows + os: windows-2025 + flags: "" + - name: Linux + os: ubuntu-24.04 + flags: -race + - name: Linux arm64 + os: ubuntu-24.04-arm + flags: -race runs-on: ${{ matrix.os }} env: CODE: ${{ needs.changes.outputs.code }} @@ -165,13 +150,10 @@ jobs: shell: bash run: go test ${{ matrix.flags }} $(go list ./... | grep -v '^flock/tools/') - # The licenses that ship with the builds; run `make notices` after changing dependencies. - name: Third-party notices if: env.CODE == 'true' && matrix.name == 'macOS' run: go run ./tools/notices -check - # Tools run on Linux when their source or shared Go module metadata changes. Product-only PRs do - # not need to run these tests, but non-PR builds run the full suite. tool-test: name: Tool tests needs: changes @@ -192,340 +174,31 @@ jobs: - name: Test run: go test ./tools/... - # On pull requests the apps are only compiled and tested (unsigned, no secrets, so forks get it - # too); the signed builds below run in the merge queue and for releases. - macos-check: - name: macOS app - needs: changes - if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true' - runs-on: macos-26 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - - # Every target, FlockScreenshots too: it reaches the app's views through @testable import. - - name: Build - run: swift build --package-path macos - - - name: Test - run: swift test --package-path macos - - windows-check: - name: Windows app - needs: changes - if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true' - runs-on: windows-2025 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 10.0.x - - - name: Test - run: dotnet test windows/Flock.Kit.Tests - - # FlockScreenshots compiles the app's sources with SCREENSHOTS defined, so a change can break - # it without touching it. - - name: Build - run: | - dotnet build windows/Flock.Tray -c Release -p:Platform=x64 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - dotnet build windows/Flock.Screenshots -c Release -p:Platform=x64 - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - macos: - name: macOS (Apple silicon) - needs: version - if: github.event_name != 'pull_request' - runs-on: macos-26 - # Holds the signing secrets; only what may be released runs in it (builds by hand stay unsigned). - environment: ${{ (github.event_name == 'merge_group' || inputs.version != '') && 'release' || '' }} - env: - VERSION: ${{ needs.version.outputs.version }} - SIGNING: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} - # build-app.sh builds it in as SUPublicEDKey; Sparkle installs only updates signed with its - # private half (SPARKLE_PRIVATE_KEY, used by tools/release). - SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.event.pull_request.head.sha }} - - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - - name: Test - run: swift test --package-path macos - - # Builds by hand may be ad-hoc signed; what gets released may not. - - name: Require signing - if: env.SIGNING != 'true' && (github.event_name == 'merge_group' || inputs.version != '') - run: echo "::error::the MACOS_* signing secrets are not set" && exit 1 - - # Without the key, the app would never update itself. - - name: Require the update key - if: env.SPARKLE_PUBLIC_KEY == '' && (github.event_name == 'merge_group' || inputs.version != '') - run: echo "::error::the SPARKLE_PUBLIC_KEY variable is not set (go run ./tools/release keygen)" && exit 1 - - # Into a keychain of its own; build-app.sh signs with $SIGN_IDENTITY and notarizes with the - # API key at $NOTARY_KEY_PATH. - - name: Set up signing - if: env.SIGNING == 'true' - env: - CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} - CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - run: | - keychain="$RUNNER_TEMP/signing.keychain-db" - password=$(uuidgen) - security create-keychain -p "$password" "$keychain" - security set-keychain-settings -lut 21600 "$keychain" - security unlock-keychain -p "$password" "$keychain" - echo "$CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" - security import "$RUNNER_TEMP/certificate.p12" -k "$keychain" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign - rm "$RUNNER_TEMP/certificate.p12" - # The intermediate certificate, in case the runner image lacks it. - curl -fsSL https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer -o "$RUNNER_TEMP/DeveloperIDG2CA.cer" - security import "$RUNNER_TEMP/DeveloperIDG2CA.cer" -k "$keychain" || true - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" > /dev/null - security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') - - identity=$(security find-identity -v -p codesigning "$keychain" | grep -o '"Developer ID Application: [^"]*"' | head -1 | tr -d '"') - [ -n "$identity" ] || { echo "::error::no Developer ID Application identity in MACOS_CERTIFICATE_P12"; exit 1; } - echo "SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" - - printf '%s\n' "$NOTARY_KEY" > "$RUNNER_TEMP/notary-key.p8" - { - echo "NOTARY_KEY_PATH=$RUNNER_TEMP/notary-key.p8" - echo "NOTARY_KEY_ID=$NOTARY_KEY_ID" - echo "NOTARY_ISSUER_ID=$NOTARY_ISSUER_ID" - } >> "$GITHUB_ENV" - - - name: Build app and disk image - run: | - macos/build-app.sh - mv build/macos/Flock.dmg "build/macos/Flock-$VERSION-macOS.dmg" - - - uses: actions/upload-artifact@v7 - with: - name: flock-macos - path: build/macos/Flock-*-macOS.dmg - if-no-files-found: error - - - name: Clean up signing - if: always() && env.SIGNING == 'true' - run: | - security delete-keychain "$RUNNER_TEMP/signing.keychain-db" || true - rm -f "$RUNNER_TEMP/notary-key.p8" + needs: [ version, changes ] + uses: ./.github/workflows/ci-macos.yml + with: + ref: ${{ inputs.ref || github.event.pull_request.head.sha }} + version: ${{ needs.version.outputs.version }} + code: ${{ needs.changes.outputs.code }} + secrets: inherit windows: - name: Windows (x64) - needs: version - if: github.event_name != 'pull_request' - runs-on: windows-2025 - # As for macOS: the signing variables, and what Azure's federated credential trusts. - environment: ${{ (github.event_name == 'merge_group' || inputs.version != '') && 'release' || '' }} + needs: [ version, changes ] permissions: contents: read id-token: write - env: - FLOCK_VERSION: ${{ needs.version.outputs.version }} - SIGNING: ${{ vars.ARTIFACT_SIGNING_ACCOUNT != '' }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.event.pull_request.head.sha }} - - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod + uses: ./.github/workflows/ci-windows.yml + with: + ref: ${{ inputs.ref || github.event.pull_request.head.sha }} + version: ${{ needs.version.outputs.version }} + code: ${{ needs.changes.outputs.code }} + secrets: inherit - - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 10.0.x - - - name: Install Inno Setup - run: | - if (-not (Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe")) { - choco install innosetup --no-progress -y - } - - - name: Require signing - if: env.SIGNING != 'true' && (github.event_name == 'merge_group' || inputs.version != '') - run: echo "::error::the ARTIFACT_SIGNING_* variables are not set" && exit 1 - - # No secret: Azure trusts this repository's "release" environment (a federated credential). - - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 - if: env.SIGNING == 'true' - with: - client-id: ${{ vars.AZURE_CLIENT_ID }} - tenant-id: ${{ vars.AZURE_TENANT_ID }} - allow-no-subscriptions: true - - # SignTool's Artifact Signing plugin and its settings; windows/sign.ps1 signs with them. - - name: Set up signing - if: env.SIGNING == 'true' - env: - ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }} - ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }} - PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE }} - run: | - $client = "$env:RUNNER_TEMP\artifact-signing" - Invoke-WebRequest https://api.nuget.org/v3-flatcontainer/microsoft.artifactsigning.client/1.0.146/microsoft.artifactsigning.client.1.0.146.nupkg -OutFile "$client.zip" - if ((Get-FileHash "$client.zip" -Algorithm SHA256).Hash -ne "7761E5D8F31E54B764DA21D33CD8B0A2940E1B05526C7DFCB5E94B0431426F70") { - throw "unexpected Microsoft.ArtifactSigning.Client package" - } - Expand-Archive "$client.zip" $client - # Signs as the Azure CLI login above, nothing else. - @{ - Endpoint = $env:ENDPOINT; CodeSigningAccountName = $env:ACCOUNT; CertificateProfileName = $env:PROFILE - ExcludeCredentials = @("EnvironmentCredential", "ManagedIdentityCredential", "WorkloadIdentityCredential", - "SharedTokenCacheCredential", "VisualStudioCredential", "VisualStudioCodeCredential", - "AzurePowerShellCredential", "AzureDeveloperCliCredential", "InteractiveBrowserCredential") - } | ConvertTo-Json | Set-Content "$client\metadata.json" - # Takes the token now, while GitHub's short-lived ID token behind the login is still valid; - # the CLI keeps it for the signing after the build. - az account get-access-token --resource https://codesigning.azure.net --output none - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - "SIGN_DLIB=$client\bin\x64\Azure.CodeSigning.Dlib.dll" >> $env:GITHUB_ENV - "SIGN_METADATA=$client\metadata.json" >> $env:GITHUB_ENV - - - name: Build installer - run: | - ./windows/build-installer.ps1 - Move-Item windows/dist/Flock-Setup-x64.exe "windows/dist/Flock-Setup-$env:FLOCK_VERSION-x64.exe" - - - name: Verify signature - if: env.SIGNING == 'true' - run: | - $signature = Get-AuthenticodeSignature windows/dist/Flock-Setup-*-x64.exe - $signature | Format-List Status, StatusMessage, SignerCertificate, TimeStamperCertificate - if ($signature.Status -ne "Valid") { throw "the installer's signature is $($signature.Status)" } - - - uses: actions/upload-artifact@v7 - with: - name: flock-windows - path: windows/dist/Flock-Setup-*-x64.exe - if-no-files-found: error - - # The Linux app (linux/, Rust with GTK 4 and libadwaita) against Ubuntu 24.04's GTK 4.14 and - # libadwaita 1.5, the oldest Flock supports. - linux-app: - name: Linux app - needs: [ version, changes ] - runs-on: ubuntu-24.04 - env: - CODE: ${{ needs.changes.outputs.code }} - steps: - - uses: actions/checkout@v7 - if: env.CODE == 'true' - with: - ref: ${{ inputs.ref || github.event.pull_request.head.sha }} - - - name: GTK, libadwaita and WebKitGTK - if: env.CODE == 'true' - # The runner image's package lists go stale: updated packages 404. - run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev - - - name: Format - if: env.CODE == 'true' - run: cargo fmt --manifest-path linux/Cargo.toml --check - - - name: Clippy - if: env.CODE == 'true' - run: cargo clippy --manifest-path linux/Cargo.toml --all-targets --all-features --locked -- -D warnings - - - name: Test - if: env.CODE == 'true' - run: cargo test --manifest-path linux/Cargo.toml --all-features --locked - - # The flockphp .deb and .rpm per architecture (linux/build-packages.sh): the CLI and the app, - # built on Ubuntu 24.04 (the oldest GTK and glibc Flock supports), then installed, run and - # removed again on the distributions Flock supports. Unsigned packages: the repository signs - # its index, not the packages. linux: - name: Linux (${{ matrix.arch }}) - needs: version - strategy: - fail-fast: false - matrix: - include: - - { arch: amd64, os: ubuntu-24.04 } - - { arch: arm64, os: ubuntu-24.04-arm } - runs-on: ${{ matrix.os }} - env: - VERSION: ${{ needs.version.outputs.version }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.event.pull_request.head.sha }} - - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - - name: GTK, libadwaita and WebKitGTK - # The runner image's package lists go stale: updated packages 404. - run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev - - - name: Build packages - run: linux/build-packages.sh "${{ matrix.arch }}" - - - name: Install, run and remove - run: | - for image in ubuntu:24.04 ubuntu:26.04 fedora:latest; do - echo "::group::$image" - case "$image" in - fedora*) install='dnf install -y -q /pkgs/flockphp*.rpm'; remove='dnf remove -y -q flockphp' ;; - *) install='apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq /pkgs/flockphp*.deb' - remove='DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq flockphp' ;; - esac - docker run --rm -v "$PWD/build/linux:/pkgs:ro" "$image" sh -ec " - $install - test -x /usr/libexec/flockphp/flock && test -f /usr/lib/systemd/system/flock@.service - test -f /usr/share/applications/dev.flock.Flock.desktop - ! ldd /usr/libexec/flockphp/flock-app | grep 'not found' - flk version - flk --help >/dev/null - $remove - ! test -e /usr/bin/flk - " - echo "::endgroup::" - done - - - name: Install, run and remove on Arch (AUR package) - # The AUR's flockphp-bin repackages the release's .deb: built here from this one with - # makepkg (as a user, as AUR helpers do). Arch has no arm64 image. - if: matrix.arch == 'amd64' - run: | - docker run --rm -e VERSION -v "$PWD/build/linux:/pkgs:ro" -v "$PWD/linux/packaging/aur/flockphp:/aur:ro" archlinux:latest sh -ec ' - pacman -Syu --noconfirm --needed base-devel >/dev/null - useradd -m builder - cp -r /aur /home/builder/pkg && cp /pkgs/flockphp_*_amd64.deb /home/builder/pkg/ - cd /home/builder/pkg - sed -i -e "s|^_tag=.*|_tag=$VERSION|" -e "s|\"https://[^\"]*/|\"|" PKGBUILD - chown -R builder . && su builder -c "makepkg --nodeps --noconfirm" - pacman -U --noconfirm flockphp-bin-*.pkg.tar.zst - test -x /usr/libexec/flockphp/flock && test -f /usr/lib/systemd/system/flock@.service - test -f /usr/share/applications/dev.flock.Flock.desktop && test -f /usr/share/polkit-1/rules.d/50-flock.rules - ! ldd /usr/libexec/flockphp/flock-app | grep "not found" - flk version - flk --help >/dev/null - pacman -R --noconfirm flockphp-bin - ! test -e /usr/bin/flk - ' - - - uses: actions/upload-artifact@v7 - with: - name: flock-linux-${{ matrix.arch }} - path: | - build/linux/flockphp*.deb - build/linux/flockphp*.rpm - if-no-files-found: error + needs: [ version, changes ] + uses: ./.github/workflows/ci-linux.yml + with: + ref: ${{ inputs.ref || github.event.pull_request.head.sha }} + version: ${{ needs.version.outputs.version }} + code: ${{ needs.changes.outputs.code }} diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml new file mode 100644 index 0000000..bf51305 --- /dev/null +++ b/.github/workflows/ci-linux.yml @@ -0,0 +1,148 @@ +name: Linux build + +on: + workflow_call: + inputs: + ref: + type: string + required: true + version: + type: string + required: true + code: + type: string + required: true + +jobs: + linux-app: + name: Linux app + runs-on: ubuntu-24.04 + env: + CODE: ${{ inputs.code }} + steps: + - uses: actions/checkout@v7 + if: env.CODE == 'true' + with: + ref: ${{ inputs.ref }} + + - name: GTK, libadwaita and WebKitGTK + if: env.CODE == 'true' + run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev + + - name: Format + if: env.CODE == 'true' + run: cargo fmt --manifest-path linux/Cargo.toml --check + + - name: Clippy + if: env.CODE == 'true' + run: cargo clippy --manifest-path linux/Cargo.toml --all-targets --all-features --locked -- -D warnings + + - name: Test + if: env.CODE == 'true' + run: cargo test --manifest-path linux/Cargo.toml --all-features --locked + + linux: + name: Linux (${{ matrix.arch }}) + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + os: ubuntu-24.04 + - arch: arm64 + os: ubuntu-24.04-arm + runs-on: ${{ matrix.os }} + env: + VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: GTK, libadwaita and WebKitGTK + run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev + + - name: Build packages + run: linux/build-packages.sh "${{ matrix.arch }}" + + - uses: actions/upload-artifact@v7 + with: + name: flock-linux-${{ matrix.arch }} + path: | + build/linux/flockphp*.deb + build/linux/flockphp*.rpm + if-no-files-found: error + + linux-package-validation: + name: Linux package validation (${{ matrix.name }}) + needs: linux + strategy: + fail-fast: false + matrix: + include: + - name: Ubuntu 24.04 + arch: amd64 + runner: ubuntu-24.04 + image: ubuntu:24.04 + package: deb + - name: Ubuntu 26.04 + arch: amd64 + runner: ubuntu-24.04 + image: ubuntu:26.04 + package: deb + - name: Fedora + arch: amd64 + runner: ubuntu-24.04 + image: fedora:latest + package: rpm + - name: Ubuntu 24.04 ARM64 + arch: arm64 + runner: ubuntu-24.04-arm + image: ubuntu:24.04 + package: deb + - name: Ubuntu 26.04 ARM64 + arch: arm64 + runner: ubuntu-24.04-arm + image: ubuntu:26.04 + package: deb + - name: Fedora ARM64 + arch: arm64 + runner: ubuntu-24.04-arm + image: fedora:latest + package: rpm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - uses: actions/download-artifact@v8 + with: + name: flock-linux-${{ matrix.arch }} + path: build/linux + + - name: Install, run and remove + run: bash linux/validate-package.sh "${{ matrix.package }}" "${{ matrix.image }}" build/linux + + linux-aur-validation: + name: Linux AUR validation + needs: linux + runs-on: ubuntu-24.04 + env: + VERSION: ${{ inputs.version }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - uses: actions/download-artifact@v8 + with: + name: flock-linux-amd64 + path: build/linux + + - name: Build, install, run and remove + run: bash linux/validate-package.sh aur archlinux:latest build/linux diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml new file mode 100644 index 0000000..1004c34 --- /dev/null +++ b/.github/workflows/ci-macos.yml @@ -0,0 +1,118 @@ +name: macOS build + +on: + workflow_call: + inputs: + ref: + type: string + required: true + version: + type: string + required: true + code: + type: string + required: true + +jobs: + macos-check: + name: macOS app + if: github.event_name == 'pull_request' && inputs.code == 'true' + runs-on: macos-26 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + # Every target, FlockScreenshots too: it reaches the app's views through @testable import. + - name: Build + run: swift build --package-path macos + + - name: Test + run: swift test --package-path macos + + macos: + name: macOS (Apple silicon) + if: github.event_name != 'pull_request' + runs-on: macos-26 + # Holds the signing secrets; only what may be released runs in it (builds by hand stay unsigned). + environment: ${{ (github.event_name == 'merge_group' || inputs.version != '') && 'release' || '' }} + env: + VERSION: ${{ inputs.version }} + SIGNING: ${{ secrets.MACOS_CERTIFICATE_P12 != '' }} + # build-app.sh builds it in as SUPublicEDKey; Sparkle installs only updates signed with its + # private half (SPARKLE_PRIVATE_KEY, used by tools/release). + SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Test + run: swift test --package-path macos + + # Builds by hand may be ad-hoc signed; what gets released may not. + - name: Require signing + if: env.SIGNING != 'true' && (github.event_name == 'merge_group' || inputs.version != '') + run: echo "::error::the MACOS_* signing secrets are not set" && exit 1 + + # Without the key, the app would never update itself. + - name: Require the update key + if: env.SPARKLE_PUBLIC_KEY == '' && (github.event_name == 'merge_group' || inputs.version != '') + run: echo "::error::the SPARKLE_PUBLIC_KEY variable is not set (go run ./tools/release keygen)" && exit 1 + + # Into a keychain of its own; build-app.sh signs with $SIGN_IDENTITY and notarizes with the + # API key at $NOTARY_KEY_PATH. + - name: Set up signing + if: env.SIGNING == 'true' + env: + CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} + CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + #language=bash + run: | + keychain="$RUNNER_TEMP/signing.keychain-db" + password=$(uuidgen) + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + echo "$CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" -k "$keychain" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign + rm "$RUNNER_TEMP/certificate.p12" + curl -fsSL https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer -o "$RUNNER_TEMP/DeveloperIDG2CA.cer" + security import "$RUNNER_TEMP/DeveloperIDG2CA.cer" -k "$keychain" || true + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" > /dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + identity=$(security find-identity -v -p codesigning "$keychain" | grep -o '"Developer ID Application: [^"]*"' | head -1 | tr -d '"') + [ -n "$identity" ] || { echo "::error::no Developer ID Application identity in MACOS_CERTIFICATE_P12"; exit 1; } + echo "SIGN_IDENTITY=$identity" >> "$GITHUB_ENV" + printf '%s\n' "$NOTARY_KEY" > "$RUNNER_TEMP/notary-key.p8" + { + echo "NOTARY_KEY_PATH=$RUNNER_TEMP/notary-key.p8" + echo "NOTARY_KEY_ID=$NOTARY_KEY_ID" + echo "NOTARY_ISSUER_ID=$NOTARY_ISSUER_ID" + } >> "$GITHUB_ENV" + + - name: Build app and disk image + #language=bash + run: | + macos/build-app.sh + mv build/macos/Flock.dmg "build/macos/Flock-$VERSION-macOS.dmg" + + - uses: actions/upload-artifact@v7 + with: + name: flock-macos + path: build/macos/Flock-*-macOS.dmg + if-no-files-found: error + + - name: Clean up signing + if: always() && env.SIGNING == 'true' + #language=bash + run: | + security delete-keychain "$RUNNER_TEMP/signing.keychain-db" || true + rm -f "$RUNNER_TEMP/notary-key.p8" diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml new file mode 100644 index 0000000..4ee8474 --- /dev/null +++ b/.github/workflows/ci-windows.yml @@ -0,0 +1,129 @@ +name: Windows build + +on: + workflow_call: + inputs: + ref: + type: string + required: true + version: + type: string + required: true + code: + type: string + required: true + +jobs: + windows-check: + name: Windows app (${{ matrix.name }}) + if: github.event_name == 'pull_request' && inputs.code == 'true' + strategy: + fail-fast: false + matrix: + include: + - name: Kit tests + command: 'dotnet test windows/Flock.Kit.Tests' + - name: Tray build + command: 'dotnet build windows/Flock.Tray -c Release -p:Platform=x64' + # FlockScreenshots compiles the app's sources with SCREENSHOTS defined, so a change can break + # it without touching it. + - name: Screenshot build + command: 'dotnet build windows/Flock.Screenshots -c Release -p:Platform=x64' + runs-on: windows-2025 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: ${{ matrix.name }} + run: ${{ matrix.command }} + + windows: + name: Windows (x64) + if: github.event_name != 'pull_request' + runs-on: windows-2025 + environment: ${{ (github.event_name == 'merge_group' || inputs.version != '') && 'release' || '' }} + permissions: + contents: read + id-token: write + env: + FLOCK_VERSION: ${{ inputs.version }} + SIGNING: ${{ vars.ARTIFACT_SIGNING_ACCOUNT != '' }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: Install Inno Setup + #language=powershell + run: | + if (-not (Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe")) { + choco install innosetup --no-progress -y + } + + - name: Require signing + if: env.SIGNING != 'true' && (github.event_name == 'merge_group' || inputs.version != '') + run: echo "::error::the ARTIFACT_SIGNING_* variables are not set" && exit 1 + + - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + if: env.SIGNING == 'true' + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + allow-no-subscriptions: true + + - name: Set up signing + if: env.SIGNING == 'true' + env: + ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT }} + ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT }} + PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE }} + #language=powershell + run: | + $client = "$env:RUNNER_TEMP\artifact-signing" + Invoke-WebRequest https://api.nuget.org/v3-flatcontainer/microsoft.artifactsigning.client/1.0.146/microsoft.artifactsigning.client.1.0.146.nupkg -OutFile "$client.zip" + if ((Get-FileHash "$client.zip" -Algorithm SHA256).Hash -ne "7761E5D8F31E54B764DA21D33CD8B0A2940E1B05526C7DFCB5E94B0431426F70") { + throw "unexpected Microsoft.ArtifactSigning.Client package" + } + Expand-Archive "$client.zip" $client + @{ + Endpoint = $env:ENDPOINT; CodeSigningAccountName = $env:ACCOUNT; CertificateProfileName = $env:PROFILE + ExcludeCredentials = @("EnvironmentCredential", "ManagedIdentityCredential", "WorkloadIdentityCredential", + "SharedTokenCacheCredential", "VisualStudioCredential", "VisualStudioCodeCredential", + "AzurePowerShellCredential", "AzureDeveloperCliCredential", "InteractiveBrowserCredential") + } | ConvertTo-Json | Set-Content "$client\metadata.json" + az account get-access-token --resource https://codesigning.azure.net --output none + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "SIGN_DLIB=$client\bin\x64\Azure.CodeSigning.Dlib.dll" >> $env:GITHUB_ENV + "SIGN_METADATA=$client\metadata.json" >> $env:GITHUB_ENV + + - name: Build installer + #language=powershell + run: | + ./windows/build-installer.ps1 + Move-Item windows/dist/Flock-Setup-x64.exe "windows/dist/Flock-Setup-$env:FLOCK_VERSION-x64.exe" + + - name: Verify signature + if: env.SIGNING == 'true' + run: | + $signature = Get-AuthenticodeSignature windows/dist/Flock-Setup-*-x64.exe + $signature | Format-List Status, StatusMessage, SignerCertificate, TimeStamperCertificate + if ($signature.Status -ne "Valid") { throw "the installer's signature is $($signature.Status)" } + + - uses: actions/upload-artifact@v7 + with: + name: flock-windows + path: windows/dist/Flock-Setup-*-x64.exe + if-no-files-found: error diff --git a/linux/validate-package.sh b/linux/validate-package.sh new file mode 100644 index 0000000..fbe365d --- /dev/null +++ b/linux/validate-package.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Installs, checks, and removes a Flock package in its target container. +# +# Usage: linux/validate-package.sh +# +# VERSION is required for an AUR validation. The AUR package repackages the AMD64 .deb. +set -euo pipefail + +package_type="${1:?package type is required}" +image="${2:?container image is required}" +package_dir="$(cd "${3:?package directory is required}" && pwd)" +repo="$(cd "$(dirname "$0")/.." && pwd)" + +case "$package_type" in + deb) + install='apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq /pkgs/flockphp*.deb' + remove='DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq flockphp' + check_polkit=false + ;; + rpm) + install='dnf install -y -q /pkgs/flockphp*.rpm' + remove='dnf remove -y -q flockphp' + check_polkit=false + ;; + aur) + : "${VERSION:?VERSION is required for AUR validation}" + docker run --rm -e VERSION -v "$package_dir:/pkgs:ro" -v "$repo/linux/packaging/aur/flockphp:/aur:ro" "$image" sh -ec ' + pacman -Syu --noconfirm --needed base-devel >/dev/null + useradd -m builder + cp -r /aur /home/builder/pkg && cp /pkgs/flockphp_*_amd64.deb /home/builder/pkg/ + cd /home/builder/pkg + sed -i -e "s|^_tag=.*|_tag=$VERSION|" -e "s|\"https://[^\"]*/|\"|" PKGBUILD + chown -R builder . && su builder -c "makepkg --nodeps --noconfirm" + pacman -U --noconfirm flockphp-bin-*.pkg.tar.zst + test -x /usr/libexec/flockphp/flock && test -f /usr/lib/systemd/system/flock@.service + test -f /usr/share/applications/dev.flock.Flock.desktop && test -f /usr/share/polkit-1/rules.d/50-flock.rules + ! ldd /usr/libexec/flockphp/flock-app | grep "not found" + flk version + flk --help >/dev/null + pacman -R --noconfirm flockphp-bin + ! test -e /usr/bin/flk + ' + exit 0 + ;; + *) + echo "Unsupported package type: $package_type" >&2 + exit 2 + ;; +esac + +docker run --rm -v "$package_dir:/pkgs:ro" "$image" sh -ec " + $install + test -x /usr/libexec/flockphp/flock && test -f /usr/lib/systemd/system/flock@.service + test -f /usr/share/applications/dev.flock.Flock.desktop + ! ldd /usr/libexec/flockphp/flock-app | grep 'not found' + flk version + flk --help >/dev/null + $remove + ! test -e /usr/bin/flk +" From 0feb8faf313b669dba6b5195546c0d59fb4cd931 Mon Sep 17 00:00:00 2001 From: Marc Beinder Date: Fri, 6 Nov 2026 15:43:48 -0600 Subject: [PATCH 2/5] fix(daemon): race condition in caddy server and sharing --- internal/web/share.go | 101 ++++++++----------------------------- internal/web/share_test.go | 25 +++++++++ 2 files changed, 45 insertions(+), 81 deletions(-) diff --git a/internal/web/share.go b/internal/web/share.go index 06b501b..899533d 100644 --- a/internal/web/share.go +++ b/internal/web/share.go @@ -1,7 +1,7 @@ package web import ( - "bufio" + "bytes" "fmt" "io" "mime" @@ -187,17 +187,16 @@ func blockedSharePath(p string) bool { return false } -// rewriteWriter passes a response through, except a page while the dev server runs: that goes -// through viteAssets.rewriteHTML on its way out, streamed (not buffered) so streamed pages still -// stream. +// rewriteWriter passes a response through, except an HTML page while the dev server runs. Those +// pages are buffered until the handler returns, then viteAssets.rewriteHTML rewrites them. type rewriteWriter struct { *caddyhttp.ResponseWriterWrapper - assets *viteAssets - head bool + assets *viteAssets + head bool + buffer bytes.Buffer + recorder caddyhttp.ResponseRecorder wroteHeader bool - chunks chan []byte // to the rewriting goroutine, when rewriting - done chan struct{} } // rewrites reports whether a response is a page to rewrite: a complete, uncompressed HTML 200. @@ -217,9 +216,10 @@ func (w *rewriteWriter) WriteHeader(status int) { if !w.head && rewrites(status, w.Header()) { w.Header().Del("Content-Length") w.Header().Del("ETag") - w.chunks, w.done = make(chan []byte, 16), make(chan struct{}) - w.ResponseWriter.WriteHeader(status) - go w.rewrite() + w.recorder = caddyhttp.NewResponseRecorder(w.ResponseWriter, &w.buffer, func(int, http.Header) bool { + return true + }) + w.recorder.WriteHeader(status) return } if rewrites(status, w.Header()) { @@ -232,11 +232,10 @@ func (w *rewriteWriter) Write(p []byte) (int, error) { if !w.wroteHeader { w.WriteHeader(http.StatusOK) } - if w.chunks == nil { - return w.ResponseWriter.Write(p) + if w.recorder != nil { + return w.recorder.Write(p) } - w.chunks <- append([]byte(nil), p...) - return len(p), nil + return w.ResponseWriter.Write(p) } // ReadFrom keeps io.Copy (which the FastCGI proxy uses) from writing past the rewrite. @@ -244,79 +243,19 @@ func (w *rewriteWriter) ReadFrom(r io.Reader) (int64, error) { if !w.wroteHeader { w.WriteHeader(http.StatusOK) } - if w.chunks == nil { - return io.Copy(w.ResponseWriter, r) - } - return io.Copy(writerOnly{w}, r) -} - -// writerOnly hides ReadFrom, so io.Copy goes through Write. -type writerOnly struct{ io.Writer } - -// Flush is left to the rewriting goroutine while it runs: it flushes whenever it has caught up. -func (w *rewriteWriter) Flush() { - if w.chunks == nil { - _ = http.NewResponseController(w.ResponseWriter).Flush() + if w.recorder != nil { + return io.Copy(w.recorder, r) } + return io.Copy(w.ResponseWriter, r) } func (w *rewriteWriter) finish() { - if w.chunks != nil { - close(w.chunks) - <-w.done - } -} - -func (w *rewriteWriter) rewrite() { - defer close(w.done) - // Tokens are small; buffered, the first write is big enough for encode to compress the page. - out := bufio.NewWriterSize(w.ResponseWriter, 32<<10) - flush := func() { - if out.Flush() == nil { - _ = http.NewResponseController(w.ResponseWriter).Flush() - } - } - r := &chunkReader{chunks: w.chunks, idle: flush} - defer func() { - // Whatever happens, drain the handler's writes so it never blocks. - for range w.chunks { - } - }() - _ = w.assets.rewriteHTML(out, r) - _ = out.Flush() -} - -// chunkReader reads the chunks a handler writes, calling idle before it waits for more. -type chunkReader struct { - chunks <-chan []byte - idle func() - cur []byte -} - -func (r *chunkReader) Read(p []byte) (int, error) { - for len(r.cur) == 0 { - select { - case c, ok := <-r.chunks: - if !ok { - return 0, io.EOF - } - r.cur = c - continue - default: - } - r.idle() - c, ok := <-r.chunks - if !ok { - return 0, io.EOF - } - r.cur = c + if w.recorder != nil { + w.ResponseWriter.WriteHeader(w.recorder.Status()) + _ = w.assets.rewriteHTML(w.ResponseWriter, w.recorder.Buffer()) } - n := copy(p, r.cur) - r.cur = r.cur[n:] - return n, nil } var ( _ caddyhttp.MiddlewareHandler = (*shareHandler)(nil) - _ http.Flusher = (*rewriteWriter)(nil) ) diff --git a/internal/web/share_test.go b/internal/web/share_test.go index ae4a665..9a9a87b 100644 --- a/internal/web/share_test.go +++ b/internal/web/share_test.go @@ -233,6 +233,31 @@ func TestShareHandlerRewritesOnlyPagesWhileTheDevServerRuns(t *testing.T) { } } +func TestShareHandlerWritesRewrittenPagesAfterTheInnerHandlerReturns(t *testing.T) { + public := viteProject(t, "http://localhost:5173") + h := shareHandler{Public: public} + rec := httptest.NewRecorder() + page := `` + + err := h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil), caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error { + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + w.WriteHeader(http.StatusOK) + if _, err := io.WriteString(w, page); err != nil { + return err + } + if rec.Body.Len() != 0 { + t.Fatalf("rewritten response was written before the inner handler returned: %q", rec.Body.String()) + } + return nil + })) + if err != nil { + t.Fatal(err) + } + if got := rec.Body.String(); !strings.Contains(got, "/build/assets/app-1a2b.js") { + t.Fatalf("page not rewritten after the inner handler returned: %q", got) + } +} + func asHandlerError(err error, target *caddyhttp.HandlerError) bool { he, ok := err.(caddyhttp.HandlerError) if ok { From 12c5b9511b7c651281def031ca2d659249acfa1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:54:16 +0000 Subject: [PATCH 3/5] Resolve merge conflicts with main Co-authored-by: onairmarc <50760632+onairmarc@users.noreply.github.com> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index b98515b..116f6a4 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( golang.org/x/net v0.59.0 golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 + gopkg.in/yaml.v3 v3.0.1 howett.net/plist v1.0.1 rsc.io/qr v0.2.0 ) @@ -177,5 +178,4 @@ require ( google.golang.org/grpc v1.83.2 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect google.golang.org/protobuf v1.36.12 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) From a93e841cc5500aec923ccfaff67845dc587c39c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:55:37 +0000 Subject: [PATCH 4/5] Restore unrelated module metadata Co-authored-by: onairmarc <50760632+onairmarc@users.noreply.github.com> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 116f6a4..b98515b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,6 @@ require ( golang.org/x/net v0.59.0 golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 - gopkg.in/yaml.v3 v3.0.1 howett.net/plist v1.0.1 rsc.io/qr v0.2.0 ) @@ -178,4 +177,5 @@ require ( google.golang.org/grpc v1.83.2 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) From 7bc1a950452a74cb3cea781890abbc193b903fe6 Mon Sep 17 00:00:00 2001 From: Lukas Frey Date: Fri, 6 Nov 2026 23:05:16 +0100 Subject: [PATCH 5/5] fix(web): restore streamed page rewriting from main; #102 already fixed the race --- internal/web/share.go | 124 ++++++++++++++++++++++++++++++------- internal/web/share_test.go | 25 -------- 2 files changed, 101 insertions(+), 48 deletions(-) diff --git a/internal/web/share.go b/internal/web/share.go index 0df89c2..c2ccaee 100644 --- a/internal/web/share.go +++ b/internal/web/share.go @@ -2,7 +2,6 @@ package web import ( "bufio" - "bytes" "fmt" "io" "mime" @@ -188,16 +187,27 @@ func blockedSharePath(p string) bool { return false } -// rewriteWriter passes a response through, except an HTML page while the dev server runs. Those -// pages are buffered until the handler returns, then viteAssets.rewriteHTML rewrites them. +// rewriteWriter passes a response through, except a page while the dev server runs: that goes +// through viteAssets.rewriteHTML on its way out, streamed (not buffered) so streamed pages still +// stream. type rewriteWriter struct { *caddyhttp.ResponseWriterWrapper - assets *viteAssets - head bool - buffer bytes.Buffer - recorder caddyhttp.ResponseRecorder + assets *viteAssets + head bool wroteHeader bool + chunks chan []byte // to the rewriting goroutine, when rewriting + done chan struct{} + // header is the handler's copy of the headers while rewriting: the response's own are the + // rewriting goroutine's, since encode sends them with the first write. + header http.Header +} + +func (w *rewriteWriter) Header() http.Header { + if w.header != nil { + return w.header + } + return w.ResponseWriter.Header() } // rewrites reports whether a response is a page to rewrite: a complete, uncompressed HTML 200. @@ -217,10 +227,10 @@ func (w *rewriteWriter) WriteHeader(status int) { if !w.head && rewrites(status, w.Header()) { w.Header().Del("Content-Length") w.Header().Del("ETag") - w.recorder = caddyhttp.NewResponseRecorder(w.ResponseWriter, &w.buffer, func(int, http.Header) bool { - return true - }) - w.recorder.WriteHeader(status) + w.header = w.Header().Clone() + w.chunks, w.done = make(chan []byte, 16), make(chan struct{}) + w.ResponseWriter.WriteHeader(status) + go w.rewrite() return } if rewrites(status, w.Header()) { @@ -233,10 +243,11 @@ func (w *rewriteWriter) Write(p []byte) (int, error) { if !w.wroteHeader { w.WriteHeader(http.StatusOK) } - if w.recorder != nil { - return w.recorder.Write(p) + if w.chunks == nil { + return w.ResponseWriter.Write(p) } - return w.ResponseWriter.Write(p) + w.chunks <- append([]byte(nil), p...) + return len(p), nil } // ReadFrom keeps io.Copy (which the FastCGI proxy uses) from writing past the rewrite. @@ -244,25 +255,92 @@ func (w *rewriteWriter) ReadFrom(r io.Reader) (int64, error) { if !w.wroteHeader { w.WriteHeader(http.StatusOK) } - if w.recorder != nil { - return io.Copy(w.recorder, r) + if w.chunks == nil { + return io.Copy(w.ResponseWriter, r) } - return io.Copy(w.ResponseWriter, r) + return io.Copy(writerOnly{w}, r) } +// writerOnly hides ReadFrom, so io.Copy goes through Write. +type writerOnly struct{ io.Writer } + +// Flush is left to the rewriting goroutine while it runs: it flushes whenever it has caught up. func (w *rewriteWriter) Flush() { - if w.recorder == nil { + if w.chunks == nil { _ = http.NewResponseController(w.ResponseWriter).Flush() } } func (w *rewriteWriter) finish() { - if w.recorder != nil { - w.ResponseWriter.WriteHeader(w.recorder.Status()) - out := bufio.NewWriterSize(w.ResponseWriter, 32<<10) - _ = w.assets.rewriteHTML(out, w.recorder.Buffer()) - _ = out.Flush() + if w.chunks != nil { + close(w.chunks) + <-w.done + // Trailers the handler set (reverse_proxy passes the upstream's) still go out: those with + // http.TrailerPrefix, and those it declared in Trailer before the headers went out. + h := w.ResponseWriter.Header() + for k, v := range w.header { + if strings.HasPrefix(k, http.TrailerPrefix) { + h[k] = v + } + } + for _, names := range w.header.Values("Trailer") { + for name := range strings.SplitSeq(names, ",") { + k := http.CanonicalHeaderKey(strings.TrimSpace(name)) + if v, ok := w.header[k]; ok { + h[k] = v + } + } + } + } +} + +func (w *rewriteWriter) rewrite() { + defer close(w.done) + // Tokens are small; buffered, the first write is big enough for encode to compress the page. + out := bufio.NewWriterSize(w.ResponseWriter, 32<<10) + flush := func() { + if out.Flush() == nil { + _ = http.NewResponseController(w.ResponseWriter).Flush() + } + } + r := &chunkReader{chunks: w.chunks, idle: flush} + defer func() { + // Whatever happens, drain the handler's writes so it never blocks. + for range w.chunks { + } + }() + _ = w.assets.rewriteHTML(out, r) + _ = out.Flush() +} + +// chunkReader reads the chunks a handler writes, calling idle before it waits for more. +type chunkReader struct { + chunks <-chan []byte + idle func() + cur []byte +} + +func (r *chunkReader) Read(p []byte) (int, error) { + for len(r.cur) == 0 { + select { + case c, ok := <-r.chunks: + if !ok { + return 0, io.EOF + } + r.cur = c + continue + default: + } + r.idle() + c, ok := <-r.chunks + if !ok { + return 0, io.EOF + } + r.cur = c } + n := copy(p, r.cur) + r.cur = r.cur[n:] + return n, nil } var ( diff --git a/internal/web/share_test.go b/internal/web/share_test.go index f727139..537f1bb 100644 --- a/internal/web/share_test.go +++ b/internal/web/share_test.go @@ -234,31 +234,6 @@ func TestShareHandlerRewritesOnlyPagesWhileTheDevServerRuns(t *testing.T) { } } -func TestShareHandlerWritesRewrittenPagesAfterTheInnerHandlerReturns(t *testing.T) { - public := viteProject(t, "http://localhost:5173") - h := shareHandler{Public: public} - rec := httptest.NewRecorder() - page := `` - - err := h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil), caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error { - w.Header().Set("Content-Type", "text/html; charset=UTF-8") - w.WriteHeader(http.StatusOK) - if _, err := io.WriteString(w, page); err != nil { - return err - } - if rec.Body.Len() != 0 { - t.Fatalf("rewritten response was written before the inner handler returned: %q", rec.Body.String()) - } - return nil - })) - if err != nil { - t.Fatal(err) - } - if got := rec.Body.String(); !strings.Contains(got, "/build/assets/app-1a2b.js") { - t.Fatalf("page not rewritten after the inner handler returned: %q", got) - } -} - func asHandlerError(err error, target *caddyhttp.HandlerError) bool { he, ok := err.(caddyhttp.HandlerError) if ok {