Files
donutbrowser/.github/workflows/publish-sidecars.yml
T
2026-08-01 20:06:31 +04:00

259 lines
11 KiB
YAML

name: Publish sidecars to R2
# Publishes the `donut-proxy` sidecar to the bucket behind
# https://download.wayfern.com, which is where the Wayfern VM fleet's bootstrap
# scripts 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. The fleet is the opposite: a leased
# macOS or Windows host has no bundle, and its agent refuses to launch a browser
# at all when the sidecar is missing (agent/launcher.go). Tying publication to a
# desktop release would mean the fleet could only be unblocked by cutting one.
#
# The fleet needs exactly two targets. Other platforms get their sidecar from the
# app bundle and are deliberately not built here.
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 other unpublished and the pair
# skewed; publish what built and report the rest.
fail-fast: false
matrix:
include:
# The leased Mac mini (Apple silicon).
- platform: macos-latest
target: aarch64-apple-darwin
artifact: donut-proxy-aarch64-apple-darwin
# The leased Elastic Metal Windows box.
- platform: windows-latest
target: x86_64-pc-windows-msvc
artifact: donut-proxy-x86_64-pc-windows-msvc.exe
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-<hash>` instead of a
# version, which is what an operator reads to tell builds apart.
fetch-depth: 0
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
with:
toolchain: stable
targets: ${{ matrix.target }}
- 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 leased 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 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 CDN copy of the previous build would make a host fail
# the SHA check the bootstrap performs, 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 leased 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"