name: Publish sidecars to R2 # Publishes the `donut-proxy` sidecar to the bucket behind # https://download.wayfern.com, where remote hosts fetch it from. # # WHY THIS EXISTS SEPARATELY FROM release.yml # The desktop app ships donut-proxy INSIDE the bundle as a Tauri sidecar, so a # desktop release never needs it in a bucket. Remote execution is the opposite: # a remote host has no bundle and cannot launch a browser without the sidecar. # Tying publication to a desktop release would mean remote execution could only # be unblocked by cutting one. # # Only three targets are needed here. Everything else gets its sidecar from the # app bundle and is deliberately not built. on: workflow_dispatch: inputs: ref: description: "Git ref to build from (defaults to the triggering ref)" required: false type: string push: branches: [main] paths: # The proxy is a separate bin from the app, and republishing invalidates # the SHA an operator recorded out of band — so this is narrowed to the # modules `src/bin/proxy_server.rs` actually imports, rather than all of # src-tauri/src. # # BEST EFFORT, deliberately: the bin reaches donutbrowser_lib, so a change # deep in a shared module can alter the binary without matching a path # here. `workflow_dispatch` is the escape hatch, and the round-trip digest # check means a stale publish is visible rather than silent. - "src-tauri/src/bin/proxy_server.rs" - "src-tauri/src/proxy_server.rs" - "src-tauri/src/proxy_storage.rs" - "src-tauri/src/proxy_runner.rs" - "src-tauri/src/socks5_local.rs" - "src-tauri/src/app_dirs.rs" - "src-tauri/src/vpn/**" - "src-tauri/src/vpn_worker_storage.rs" - "src-tauri/src/xray_worker_runner.rs" - "src-tauri/src/xray/**" - "src-tauri/build.rs" - "src-tauri/Cargo.toml" - "src-tauri/Cargo.lock" - ".github/workflows/publish-sidecars.yml" concurrency: # Two overlapping runs would race on the same object keys and the loser's # bytes could win, leaving the bucket serving a build nobody recorded. group: publish-sidecars cancel-in-progress: false permissions: contents: read jobs: build: name: Build donut-proxy (${{ matrix.target }}) runs-on: ${{ matrix.platform }} strategy: # One target failing must not leave the others unpublished and the set # skewed; publish what built and report the rest. fail-fast: false matrix: include: # macOS arm64 remote host. - platform: macos-latest target: aarch64-apple-darwin artifact: donut-proxy-aarch64-apple-darwin # Windows x86_64 remote host. - platform: windows-latest target: x86_64-pc-windows-msvc artifact: donut-proxy-x86_64-pc-windows-msvc.exe # Linux x86_64 remote host. Pinned to 22.04, not -latest: the # deployment target is glibc 2.35, and a binary linked on 24.04 # (glibc 2.39) refuses to load there. The stage step proves the pin # held. - platform: ubuntu-22.04 target: x86_64-unknown-linux-gnu artifact: donut-proxy-x86_64-unknown-linux-gnu steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ inputs.ref || github.ref }} # build.rs derives BUILD_VERSION from git; a shallow clone with no tags # makes every published binary report `nightly-` instead of a # version, which is what an operator reads to tell builds apart. fetch-depth: 0 - name: Install Rust uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master with: toolchain: stable targets: ${{ matrix.target }} # The proxy bin links donutbrowser_lib, which pulls in Tauri and therefore # GTK and WebKit at link time even though the proxy never opens a window. # Same package list as release.yml, so the two cannot drift apart. - name: Install Linux build dependencies if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils - name: Build donut-proxy shell: bash working-directory: ./src-tauri env: GITHUB_REF_NAME: ${{ github.ref_name }} run: cargo build --bin donut-proxy --target ${{ matrix.target }} --release - name: Stage the binary and record its digest id: stage shell: bash working-directory: ./src-tauri run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/sidecars" src="target/${{ matrix.target }}/release/donut-proxy" [ -f "$src.exe" ] && src="$src.exe" if [ ! -f "$src" ]; then echo "::error::cargo reported success but $src does not exist" exit 1 fi dest="$RUNNER_TEMP/sidecars/${{ matrix.artifact }}" cp "$src" "$dest" chmod +x "$dest" # Prove the thing we are about to publish actually runs and is the # binary we think it is. A sidecar that cannot start is indistinguishable # from a missing one once it is on a remote host, except that it fails # later and less clearly. version="$("$dest" --version)" case "$version" in "donut-proxy "*) ;; *) echo "::error::unexpected --version output: $version"; exit 1 ;; esac if [ "$RUNNER_OS" = "Linux" ]; then # The Linux deployment target is glibc 2.35. A binary linked on a # newer runner fails there with "version GLIBC_2.xx not found", # which reaches the host only as a sidecar that "will not run". # The runner is pinned to 22.04 for that reason; this proves the # pin held, and that every library the binary names resolves at # all. fleet_glibc_max=2.35 if ! ldd_out="$(ldd "$dest")"; then echo "::error::ldd cannot read $dest" printf '%s\n' "$ldd_out" exit 1 fi if grep -q 'not found' <<< "$ldd_out"; then echo "::error::$dest needs a shared library this runner cannot resolve, and the fleet host will not either" printf '%s\n' "$ldd_out" exit 1 fi needed="$(objdump -p "$dest" | awk '$1 == "NEEDED" { print $2 }')" glibc_max="$(objdump -T "$dest" | grep -o 'GLIBC_[0-9]*\.[0-9]*' | sed 's/^GLIBC_//' | sort -uV | tail -n 1)" if [ -z "$glibc_max" ]; then echo "::error::could not read the glibc symbol versions of $dest" exit 1 fi { echo "### ${{ matrix.artifact }} shared libraries (DT_NEEDED)" echo "" echo '```' printf '%s\n' "$needed" echo '```' echo "" echo "- highest glibc symbol version: \`GLIBC_$glibc_max\` (fleet host ceiling: \`GLIBC_$fleet_glibc_max\`)" echo "" } >> "$GITHUB_STEP_SUMMARY" if [ "$(printf '%s\n' "$glibc_max" "$fleet_glibc_max" | sort -V | tail -n 1)" != "$fleet_glibc_max" ]; then echo "::error::$dest needs GLIBC_$glibc_max, but the fleet host (Ubuntu 22.04) ships glibc $fleet_glibc_max; build it on ubuntu-22.04" exit 1 fi fi if command -v sha256sum >/dev/null; then digest="$(sha256sum "$dest" | cut -d' ' -f1)" else digest="$(shasum -a 256 "$dest" | cut -d' ' -f1)" fi echo "digest=$digest" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" printf '%s %s\n' "$digest" "${{ matrix.artifact }}" \ > "$RUNNER_TEMP/sidecars/${{ matrix.artifact }}.sha256" - name: Publish to R2 shell: bash env: # The repo's existing R2 secrets (see publish-repos.yml) are preferred; # the AWS_* names are accepted because R2's S3 API is what those # credentials are for and an operator may have configured either. R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} AWS_KEY_FALLBACK: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_FALLBACK: ${{ secrets.AWS_SECRET_ACCESS_KEY }} R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # The bucket behind download.wayfern.com. Defaulted rather than # required so this works with the account's existing setup. WAYFERN_R2_BUCKET: ${{ secrets.WAYFERN_R2_BUCKET }} ARTIFACT: ${{ matrix.artifact }} DIGEST: ${{ steps.stage.outputs.digest }} run: | set -euo pipefail if ! command -v aws >/dev/null; then # Preinstalled on every GitHub-hosted image, so its absence means a # self-hosted or changed runner. Say that, rather than failing later # with "command not found" from inside a chain of pipes. echo "::error::aws CLI not found on this runner. Install aws-cli v2 or use a GitHub-hosted runner." exit 1 fi # Byte-identical to publish-repos.yml. Deliberately NOT a `tr -d` of # quote characters: that would corrupt a secret containing one, rather # than only unwrapping a value someone pasted with quotes around it. strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; } key_id="$(strip "${R2_ACCESS_KEY_ID:-}")" secret="$(strip "${R2_SECRET_ACCESS_KEY:-}")" if [ -z "$key_id" ] || [ -z "$secret" ]; then key_id="$(strip "${AWS_KEY_FALLBACK:-}")" secret="$(strip "${AWS_SECRET_FALLBACK:-}")" fi if [ -z "$key_id" ] || [ -z "$secret" ]; then echo "::error::No R2 credentials. Set R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY (preferred) or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY as repository secrets." exit 1 fi bucket="$(strip "${WAYFERN_R2_BUCKET:-}")" bucket="${bucket:-wayfern}" endpoint="$(strip "${R2_ENDPOINT_URL:-}")" if [ -z "$endpoint" ]; then account="$(strip "${CLOUDFLARE_ACCOUNT_ID:-}")" if [ -z "$account" ]; then echo "::error::Set R2_ENDPOINT_URL, or CLOUDFLARE_ACCOUNT_ID so the endpoint can be derived." exit 1 fi endpoint="https://${account}.r2.cloudflarestorage.com" fi case "$endpoint" in https://*) ;; *) endpoint="https://$endpoint" ;; esac export AWS_ACCESS_KEY_ID="$key_id" export AWS_SECRET_ACCESS_KEY="$secret" export AWS_DEFAULT_REGION="auto" # aws-cli v2.23+ sends integrity checksums by default and R2 rejects # them with `Unauthorized`. Same workaround as scripts/publish-repo.sh. export AWS_REQUEST_CHECKSUM_CALCULATION="WHEN_REQUIRED" export AWS_RESPONSE_CHECKSUM_VALIDATION="WHEN_REQUIRED" src="$RUNNER_TEMP/sidecars/$ARTIFACT" # no-cache, not a long max-age: this key is deliberately overwritten in # place, and a cached copy of the previous build would make a host # fail its integrity check, which reads as a corrupt download rather # than a stale cache. aws s3 cp "$src" "s3://${bucket}/${ARTIFACT}" \ --endpoint-url "$endpoint" \ --content-type application/octet-stream \ --cache-control "no-cache, must-revalidate" \ --only-show-errors aws s3 cp "$src.sha256" "s3://${bucket}/${ARTIFACT}.sha256" \ --endpoint-url "$endpoint" \ --content-type text/plain \ --cache-control "no-cache, must-revalidate" \ --only-show-errors # Read it back and compare. Without this, "published" is an assumption: # a truncated upload or a write to the wrong bucket both look like # success, and the failure would surface days later on a remote host # as an unexplained checksum mismatch. verify="$RUNNER_TEMP/verify-$ARTIFACT" aws s3 cp "s3://${bucket}/${ARTIFACT}" "$verify" \ --endpoint-url "$endpoint" --only-show-errors if command -v sha256sum >/dev/null; then got="$(sha256sum "$verify" | cut -d' ' -f1)" else got="$(shasum -a 256 "$verify" | cut -d' ' -f1)" fi if [ "$got" != "$DIGEST" ]; then echo "::error::Round-trip mismatch for $ARTIFACT: uploaded $DIGEST, bucket returned $got" exit 1 fi echo "Published and verified $ARTIFACT ($DIGEST)" - name: Summarise if: always() && steps.stage.outputs.digest != '' shell: bash env: ARTIFACT: ${{ matrix.artifact }} DIGEST: ${{ steps.stage.outputs.digest }} VERSION: ${{ steps.stage.outputs.version }} run: | { echo "### $ARTIFACT" echo "" echo "- version: \`$VERSION\`" echo "- sha256: \`$DIGEST\`" echo "- url: https://download.wayfern.com/$ARTIFACT" echo "" echo "The fleet bootstrap takes this digest as an argument, so copy it" echo "from here rather than fetching the published \`.sha256\` — a hash" echo "served by the same bucket as the binary verifies transport only." } >> "$GITHUB_STEP_SUMMARY"