Wire Forge-native release infrastructure
Some checks failed
Cache: Publish Nix / Publish Nix Cache (push) Waiting to run
Build Rust / Cargo Test (push) Successful in 4m14s
Build Site / Next.js Build (push) Failing after 6s
Infra: OpenTofu / OpenTofu (grafana) (push) Successful in 5s
Lint Governance / BEP Metadata (push) Successful in 0s
Build: Android / Android Rust Core Stub (push) Failing after 23s

This commit is contained in:
Conrad Kramer 2026-06-07 05:51:12 -07:00
parent 97c569fb35
commit 002bd382e9
199 changed files with 14268 additions and 185 deletions

10
.bazelignore Normal file
View file

@ -0,0 +1,10 @@
.build
.derived-data
.git
.nscloud-cache
Apple/build
ci-artifacts
dist
publish
release
target

1
.bazelversion Normal file
View file

@ -0,0 +1 @@
9.1.0

View file

@ -0,0 +1,284 @@
name: Decrypt Release Secrets
description: Decrypts age-sealed Burrow release secrets and exports CI paths/env.
inputs:
root:
description: Repository root
required: false
default: .
runs:
using: composite
steps:
- shell: bash
run: |
set -euo pipefail
ROOT_INPUT='${{ inputs.root }}'
ROOT="$(cd "$ROOT_INPUT" && pwd)"
resolve_decrypt_tool() {
local candidate
for candidate in \
"${AGE_BIN:-}" \
"$(command -v age 2>/dev/null || true)" \
/opt/homebrew/bin/age \
/usr/local/bin/age \
/usr/bin/age \
/run/current-system/sw/bin/age \
/etc/profiles/per-user/runner/bin/age \
"${HOME:-}/.nix-profile/bin/age" \
"${HOME:-}/.local/state/nix/profile/bin/age" \
/Users/runner/.nix-profile/bin/age \
/Users/runner/.local/state/nix/profile/bin/age \
/home/runner/.nix-profile/bin/age \
/home/runner/.local/state/nix/profile/bin/age \
; do
if [[ -n "$candidate" && -x "$candidate" ]]; then
printf 'age:%s\n' "$candidate"
return 0
fi
done
for candidate in \
"${AGENIX_BIN:-}" \
"$(command -v agenix 2>/dev/null || true)" \
; do
if [[ -n "$candidate" && -x "$candidate" ]]; then
printf 'agenix:%s\n' "$candidate"
return 0
fi
done
if command -v nix >/dev/null 2>&1; then
local built
built="$(nix build --no-link "$ROOT#agenix" --print-out-paths 2>/dev/null | tail -n 1 || true)"
if [[ -n "$built" && -x "$built/bin/agenix" ]]; then
printf 'agenix:%s\n' "$built/bin/agenix"
return 0
fi
fi
echo "::error ::age or agenix is required to decrypt release secrets but neither is available." >&2
exit 1
}
decrypt_tool="$(resolve_decrypt_tool)"
decrypt_kind="${decrypt_tool%%:*}"
decrypt_bin="${decrypt_tool#*:}"
KEY="${AGE_IDENTITY_PATH:-}"
if [[ -z "$KEY" || ! -s "$KEY" ]]; then
echo "::error ::Missing age identity. Run Scripts/resolve-age-identity.sh before this action." >&2
exit 1
fi
decrypt_age_file() {
local file_path="$1"
if [[ "$decrypt_kind" == "agenix" ]]; then
"$decrypt_bin" --identity "$KEY" --decrypt "$file_path"
else
"$decrypt_bin" --decrypt -i "$KEY" "$file_path"
fi
}
write_env() {
local name="$1"
local value="$2"
echo "${name}=${value}" >> "$GITHUB_ENV"
}
mask_multiline() {
local value="$1"
echo "::add-mask::${value}"
while IFS= read -r line; do
[[ -n "$line" ]] || continue
echo "::add-mask::${line}"
done <<<"$value"
}
decrypt_env_optional() {
local name="$1"
local file="$2"
if [[ ! -f "$file" ]]; then
echo "::notice ::Skipping optional release secret ${file}"
return 0
fi
local value
value="$(decrypt_age_file "$file")"
mask_multiline "$value"
{
echo "${name}<<EOF"
printf '%s\n' "$value"
echo "EOF"
} >> "$GITHUB_ENV"
}
decrypt_file_optional() {
local name="$1"
local file="$2"
local suffix="$3"
if [[ ! -f "$file" ]]; then
echo "::notice ::Skipping optional release secret ${file}"
return 0
fi
local temp_dir="${RUNNER_TEMP:-/tmp}"
local temp_file
temp_file="$(mktemp "${temp_dir}/${name}.XXXXXX")"
if [[ -n "$suffix" ]]; then
local with_suffix="${temp_file}.${suffix}"
mv "$temp_file" "$with_suffix"
temp_file="$with_suffix"
fi
decrypt_age_file "$file" > "$temp_file"
chmod 600 "$temp_file"
write_env "$name" "$temp_file"
}
decrypt_dotenv_optional() {
local file="$1"
if [[ ! -f "$file" ]]; then
echo "::notice ::Skipping optional release secret ${file}"
return 0
fi
local temp_file
temp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-release-dotenv.XXXXXX")"
decrypt_age_file "$file" > "$temp_file"
# shellcheck disable=SC1090
set -a
source "$temp_file"
set +a
rm -f "$temp_file"
if [[ -z "${MINIO_ROOT_USER:-}" || -z "${MINIO_ROOT_PASSWORD:-}" ]]; then
echo "::error ::${file} must define MINIO_ROOT_USER and MINIO_ROOT_PASSWORD." >&2
exit 1
fi
echo "::add-mask::${MINIO_ROOT_USER}"
echo "::add-mask::${MINIO_ROOT_PASSWORD}"
{
echo "AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER}"
echo "AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}"
echo "AWS_DEFAULT_REGION=${BLOB_REGION:-hel1}"
echo "AWS_REGION=${BLOB_REGION:-hel1}"
echo "AWS_S3_ENDPOINT_URL=https://${BLOB_ENDPOINT:-hel1.your-objectstorage.com}"
} >> "$GITHUB_ENV"
}
decode_base64_to_file() {
local output="$1"
python3 -c 'import base64, pathlib, sys; pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(sys.stdin.buffer.read()))' "$output"
}
decrypt_appstore_connect() {
local file="$ROOT/secrets/apple/appstore-connect.env.age"
if [[ ! -f "$file" ]]; then
echo "::notice ::Skipping optional release secret ${file}"
return 0
fi
local temp_file key_path key_value
temp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-asc-env.XXXXXX")"
decrypt_age_file "$file" > "$temp_file"
# shellcheck disable=SC1090
set -a
source "$temp_file"
set +a
rm -f "$temp_file"
if [[ -z "${ASC_API_KEY_ID:-}" || -z "${ASC_API_ISSUER_ID:-}" ]]; then
echo "::error ::${file} must define ASC_API_KEY_ID and ASC_API_ISSUER_ID." >&2
exit 1
fi
key_path="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-asc-key.XXXXXX.p8")"
if [[ -n "${ASC_API_KEY_BASE64:-}" ]]; then
printf '%s' "$ASC_API_KEY_BASE64" | decode_base64_to_file "$key_path"
elif [[ -n "${ASC_API_KEY_P8_BASE64:-}" ]]; then
printf '%s' "$ASC_API_KEY_P8_BASE64" | decode_base64_to_file "$key_path"
elif [[ -n "${APPSTORE_KEY:-}" ]]; then
printf '%s\n' "$APPSTORE_KEY" > "$key_path"
elif [[ -n "${ASC_API_KEY:-}" ]]; then
printf '%s\n' "$ASC_API_KEY" > "$key_path"
else
echo "::error ::${file} must define ASC_API_KEY_BASE64 or ASC_API_KEY." >&2
exit 1
fi
chmod 600 "$key_path"
key_value="$(cat "$key_path")"
mask_multiline "$key_value"
echo "::add-mask::${ASC_API_KEY_ID}"
echo "::add-mask::${ASC_API_ISSUER_ID}"
write_env ASC_API_KEY_ID "$ASC_API_KEY_ID"
write_env ASC_API_ISSUER_ID "$ASC_API_ISSUER_ID"
write_env ASC_API_KEY_PATH "$key_path"
}
decrypt_distribution_signing() {
local file="$ROOT/secrets/apple/distribution-signing.env.age"
if [[ ! -f "$file" ]]; then
echo "::notice ::Skipping optional release secret ${file}"
return 0
fi
local temp_file cert_path cert_password cert_base64
temp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-signing-env.XXXXXX")"
decrypt_age_file "$file" > "$temp_file"
# shellcheck disable=SC1090
set -a
source "$temp_file"
set +a
rm -f "$temp_file"
cert_base64="${APPLE_SIGNING_CERTIFICATE_BASE64:-${APPLE_DISTRIBUTION_CERTIFICATE_BASE64:-}}"
cert_password="${APPLE_SIGNING_CERTIFICATE_PASSWORD:-${APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD:-}}"
if [[ -z "$cert_password" && -n "${APPLE_SIGNING_CERTIFICATE_PASSWORD_BASE64:-}" ]]; then
cert_password="$(printf '%s' "$APPLE_SIGNING_CERTIFICATE_PASSWORD_BASE64" | python3 -c 'import base64,sys; print(base64.b64decode(sys.stdin.buffer.read()).decode(), end="")')"
fi
if [[ -z "$cert_base64" || -z "$cert_password" ]]; then
echo "::error ::${file} must define APPLE_SIGNING_CERTIFICATE_BASE64 and APPLE_SIGNING_CERTIFICATE_PASSWORD." >&2
exit 1
fi
cert_path="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-apple-signing.XXXXXX.p12")"
printf '%s' "$cert_base64" | decode_base64_to_file "$cert_path"
chmod 600 "$cert_path"
echo "::add-mask::${cert_password}"
write_env APPLE_SIGNING_CERTIFICATE_PATH "$cert_path"
write_env APPLE_SIGNING_CERTIFICATE_PASSWORD "$cert_password"
write_env APPLE_KEYCHAIN_PASSWORD "$cert_password"
}
decrypt_sparkle() {
local file="$ROOT/secrets/apple/sparkle.env.age"
if [[ ! -f "$file" ]]; then
echo "::notice ::Skipping optional release secret ${file}"
return 0
fi
local temp_file key_path key_base64 key_value
temp_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-sparkle-env.XXXXXX")"
decrypt_age_file "$file" > "$temp_file"
# shellcheck disable=SC1090
set -a
source "$temp_file"
set +a
rm -f "$temp_file"
key_base64="${SPARKLE_EDDSA_KEY_BASE64:-${SPARKLE_PRIVATE_KEY_BASE64:-}}"
key_path="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-sparkle-eddsa.XXXXXX.key")"
if [[ -n "$key_base64" ]]; then
printf '%s' "$key_base64" | decode_base64_to_file "$key_path"
elif [[ -n "${SPARKLE_EDDSA_KEY:-}" ]]; then
printf '%s\n' "$SPARKLE_EDDSA_KEY" > "$key_path"
elif [[ -n "${SPARKLE_PRIVATE_KEY:-}" ]]; then
printf '%s\n' "$SPARKLE_PRIVATE_KEY" > "$key_path"
else
echo "::error ::${file} must define SPARKLE_EDDSA_KEY_BASE64 or SPARKLE_EDDSA_KEY." >&2
exit 1
fi
chmod 600 "$key_path"
key_value="$(cat "$key_path")"
mask_multiline "$key_value"
write_env SPARKLE_EDDSA_KEY_PATH "$key_path"
}
decrypt_appstore_connect
decrypt_distribution_signing
decrypt_sparkle
decrypt_dotenv_optional "$ROOT/secrets/hetzner/object-storage.age"

View file

@ -0,0 +1,98 @@
name: "Apple: Developer ID KMS CSR"
run-name: "Apple: Developer ID KMS CSR (${{ github.event.inputs.kms_key || 'apple-developer-id-application' }})"
on:
workflow_dispatch:
inputs:
kms_key:
description: Google KMS key name in the Burrow identity key ring
required: false
default: apple-developer-id-application
kms_version:
description: Google KMS key version
required: false
default: "1"
common_name:
description: CSR common name
required: false
default: Burrow Developer ID Application
email_address:
description: CSR email address
required: false
default: release@burrow.net
permissions:
contents: read
concurrency:
group: apple-developer-id-kms-csr-${{ github.ref }}
cancel-in-progress: false
env:
BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }}
BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }}
BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }}
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }}
BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }}
BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }}
BURROW_KMS_LOCATION: global
BURROW_KMS_KEYRING: burrow-identity
jobs:
csr:
name: Generate CSR
runs-on: [self-hosted, linux, x86_64, burrow-forge]
timeout-minutes: 20
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Authenticate Google WIF
shell: bash
run: nix develop .#ci -c Scripts/ci/google-wif-auth.sh
- name: Generate KMS-backed CSR
shell: bash
env:
BURROW_APPLE_DEVELOPER_ID_KMS_KEY: ${{ github.event.inputs.kms_key || 'apple-developer-id-application' }}
BURROW_KMS_VERSION: ${{ github.event.inputs.kms_version || '1' }}
BURROW_APPLE_DEVELOPER_ID_CSR_COMMON_NAME: ${{ github.event.inputs.common_name || 'Burrow Developer ID Application' }}
BURROW_APPLE_DEVELOPER_ID_CSR_EMAIL: ${{ github.event.inputs.email_address || 'release@burrow.net' }}
run: |
set -euo pipefail
mkdir -p dist/apple-developer-id
nix develop .#ci -c Scripts/apple/google-kms-csr.py \
--output dist/apple-developer-id/developer-id-application.certSigningRequest \
--public-key-output dist/apple-developer-id/google-kms-public-key.pem
if command -v openssl >/dev/null 2>&1; then
openssl req -in dist/apple-developer-id/developer-id-application.certSigningRequest -noout -verify
fi
cat > dist/apple-developer-id/README.txt <<'EOF'
This CSR is backed by a non-exportable Google Cloud KMS key in the Burrow
identity key ring. Apple currently documents Developer ID certificate creation
through the Developer website or Xcode, not App Store Connect API creation.
Upload developer-id-application.certSigningRequest in:
Certificates, Identifiers & Profiles -> Certificates -> + -> Developer ID ->
Developer ID Application.
Keep the returned .cer file with release artifacts. The private key remains in
Google Cloud KMS and is never exported as a p12.
EOF
- name: Upload CSR Artifact
uses: https://code.forgejo.org/actions/upload-artifact@v4
with:
name: burrow-developer-id-kms-csr
path: dist/apple-developer-id

View file

@ -0,0 +1,99 @@
name: "Apple: iOS Distribution KMS CSR"
run-name: "Apple: iOS Distribution KMS CSR (${{ github.event.inputs.kms_key || 'apple-ios-distribution' }})"
on:
workflow_dispatch:
inputs:
kms_key:
description: Google KMS key name in the Burrow identity key ring
required: false
default: apple-ios-distribution
kms_version:
description: Google KMS key version
required: false
default: "1"
common_name:
description: CSR common name
required: false
default: Burrow iOS Distribution
email_address:
description: CSR email address
required: false
default: release@burrow.net
permissions:
contents: read
concurrency:
group: apple-ios-distribution-kms-csr-${{ github.ref }}
cancel-in-progress: false
env:
BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }}
BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }}
BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }}
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }}
BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }}
BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }}
BURROW_KMS_LOCATION: global
BURROW_KMS_KEYRING: burrow-identity
jobs:
csr:
name: Generate CSR
runs-on: [self-hosted, linux, x86_64, burrow-forge]
timeout-minutes: 20
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Authenticate Google WIF
shell: bash
run: nix develop .#ci -c Scripts/ci/google-wif-auth.sh
- name: Generate KMS-backed CSR
shell: bash
run: |
set -euo pipefail
mkdir -p dist/apple-ios-distribution
nix develop .#ci -c Scripts/apple/google-kms-csr.py \
--kms-key "${{ github.event.inputs.kms_key || 'apple-ios-distribution' }}" \
--kms-version "${{ github.event.inputs.kms_version || '1' }}" \
--common-name "${{ github.event.inputs.common_name || 'Burrow iOS Distribution' }}" \
--email-address "${{ github.event.inputs.email_address || 'release@burrow.net' }}" \
--output dist/apple-ios-distribution/ios-distribution.certSigningRequest \
--public-key-output dist/apple-ios-distribution/google-kms-public-key.pem
if command -v openssl >/dev/null 2>&1; then
openssl req -in dist/apple-ios-distribution/ios-distribution.certSigningRequest -noout -verify
fi
cat > dist/apple-ios-distribution/README.txt <<'EOF'
This CSR is backed by a non-exportable Google Cloud KMS key in the Burrow
identity key ring.
Create an iOS Distribution certificate explicitly through App Store Connect:
Scripts/apple/create-asc-certificate.mjs \
--certificate-type IOS_DISTRIBUTION \
--csr-file ios-distribution.certSigningRequest \
--out-dir Apple/Certificates
Keep the returned .cer file with release artifacts. The private key remains in
Google Cloud KMS and is never exported as a p12.
EOF
- name: Upload CSR Artifact
uses: https://code.forgejo.org/actions/upload-artifact@v4
with:
name: burrow-ios-distribution-kms-csr
path: dist/apple-ios-distribution

View file

@ -0,0 +1,67 @@
name: "Backup: Garage Storage"
run-name: "Backup Garage Storage (${{ github.event.inputs.set || 'all' }})"
on:
schedule:
- cron: "17 9 * * *"
workflow_dispatch:
inputs:
set:
description: "Comma-separated backup set: all, releases, packages, nix-cache"
required: false
default: all
delete_unmatched:
description: Delete destination objects that are missing from Garage
required: false
default: "false"
permissions:
contents: read
concurrency:
group: backup-garage-storage-${{ github.event.inputs.set || 'all' }}
cancel-in-progress: false
jobs:
backup:
name: Mirror Garage To GCS
runs-on: namespace-profile-linux-medium
timeout-minutes: 90
env:
BURROW_GARAGE_ENDPOINT: ${{ vars.BURROW_GARAGE_ENDPOINT || 'https://objects.burrow.net' }}
BURROW_GARAGE_REGION: ${{ vars.BURROW_GARAGE_REGION || 'garage' }}
BURROW_GARAGE_BACKUP_SET: ${{ github.event.inputs.set || 'all' }}
BURROW_GARAGE_BACKUP_DELETE: ${{ github.event.inputs.delete_unmatched || 'false' }}
BURROW_RELEASE_GARAGE_BUCKET: ${{ vars.BURROW_RELEASE_GARAGE_BUCKET || 'burrow-releases' }}
BURROW_PACKAGE_GARAGE_BUCKET: ${{ vars.BURROW_PACKAGE_GARAGE_BUCKET || 'burrow-packages' }}
BURROW_NIX_CACHE_GARAGE_BUCKET: ${{ vars.BURROW_NIX_CACHE_GARAGE_BUCKET || 'attic' }}
BURROW_RELEASE_GCS_BUCKET: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }}
BURROW_PACKAGE_GCS_BUCKET: ${{ vars.BURROW_PACKAGE_GCS_BUCKET || 'burrow-net-packages' }}
BURROW_NIX_CACHE_GCS_BUCKET: ${{ vars.BURROW_NIX_CACHE_GCS_BUCKET || 'burrow-net-nix-cache' }}
AWS_ACCESS_KEY_ID: ${{ secrets.BURROW_GARAGE_BACKUP_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.BURROW_GARAGE_BACKUP_SECRET_ACCESS_KEY }}
BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }}
BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }}
BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }}
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }}
BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }}
BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }}
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Mirror Garage Buckets To GCS
shell: bash
run: nix develop .#ci -c Scripts/ci/backup-garage-to-gcs.sh

View file

@ -0,0 +1,74 @@
name: "Build: Android"
run-name: "Build: Android (${{ github.ref_name }})"
on:
push:
branches:
- main
paths:
- ".forgejo/workflows/build-android.yml"
- "Android/**"
- "BUILD.bazel"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- "bazel/android/**"
- "Cargo.lock"
- "Cargo.toml"
- "crates/burrow-core/**"
- "crates/burrow-mobile-ffi/**"
- "rust-toolchain.toml"
- "Scripts/ci/nscloud-cache.sh"
pull_request:
paths:
- ".forgejo/workflows/build-android.yml"
- "Android/**"
- "BUILD.bazel"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- "bazel/android/**"
- "Cargo.lock"
- "Cargo.toml"
- "crates/burrow-core/**"
- "crates/burrow-mobile-ffi/**"
- "rust-toolchain.toml"
- "Scripts/ci/nscloud-cache.sh"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: burrow-build-android-${{ github.ref }}
cancel-in-progress: true
env:
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
jobs:
stub:
name: Android Rust Core Stub
runs-on: namespace-profile-linux-medium
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Configure Cache
shell: bash
run: |
set -euo pipefail
bash Scripts/ci/ensure-nix.sh
nix develop .#ci -c Scripts/ci/nscloud-cache.sh bazel
- name: Build Android Stub Check
shell: bash
run: |
set -euo pipefail
if [[ -n "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" ]]; then
bazel --bazelrc="$BURROW_BAZEL_NSCCACHE_BAZELRC" build //bazel/android:check_stub_stamp
else
bazel build //bazel/android:check_stub_stamp
fi

View file

@ -0,0 +1,443 @@
name: "Build: Release"
run-name: "Build: Release (${{ github.ref_name }})"
on:
workflow_dispatch:
inputs:
build_number_override:
description: Optional explicit build number
required: false
default: ""
upload_app_store:
description: Trigger downstream App Store Connect upload workflow
required: false
default: "false"
upload_sparkle:
description: Trigger downstream Sparkle publish workflow
required: false
default: "true"
upload_microsoft_store:
description: Trigger downstream Microsoft Store beta upload workflow
required: false
default: "false"
sparkle_point_main:
description: Update Sparkle main channel pointers
required: false
default: "true"
testflight_distribute_external:
description: Distribute to external TestFlight groups
required: false
default: "false"
testflight_groups:
description: Optional comma-separated TestFlight groups for external distribution
required: false
default: ""
ios_uses_non_exempt_encryption:
description: Set true only when the iOS build uses non-exempt encryption
required: false
default: "false"
push:
tags:
- "release/*"
permissions:
actions: write
contents: write
concurrency:
group: burrow-build-release-${{ github.ref }}
cancel-in-progress: true
env:
BURROW_RELEASE_GCS_BUCKET: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }}
BURROW_RELEASE_GCS_BACKUP: ${{ vars.BURROW_RELEASE_GCS_BACKUP || 'true' }}
BURROW_RELEASE_GARAGE_BUCKET: ${{ vars.BURROW_RELEASE_GARAGE_BUCKET || 'burrow-releases' }}
BURROW_RELEASE_REQUIRE_GARAGE: ${{ vars.BURROW_RELEASE_REQUIRE_GARAGE || 'true' }}
BURROW_GARAGE_ENDPOINT: ${{ vars.BURROW_GARAGE_ENDPOINT || 'https://objects.burrow.net' }}
BURROW_GARAGE_REGION: ${{ vars.BURROW_GARAGE_REGION || 'garage' }}
AWS_ACCESS_KEY_ID: ${{ secrets.BURROW_GARAGE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.BURROW_GARAGE_SECRET_ACCESS_KEY }}
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }}
BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }}
BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }}
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }}
BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }}
BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }}
BURROW_APPLE_BUNDLE_ID: ${{ vars.BURROW_APPLE_BUNDLE_ID || 'net.burrow.app' }}
BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID: ${{ vars.BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID || 'net.burrow.app.network' }}
BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID: ${{ vars.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID || '3G42677598' }}
BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS: ${{ vars.BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS || 'false' }}
BURROW_APPLE_ENABLE_CAPABILITIES: ${{ vars.BURROW_APPLE_ENABLE_CAPABILITIES || 'false' }}
BURROW_SPARKLE_SIGN_WITH_KMS: ${{ vars.BURROW_SPARKLE_SIGN_WITH_KMS || 'false' }}
BURROW_SPARKLE_KMS_KEY: ${{ vars.BURROW_SPARKLE_KMS_KEY || 'sparkle-ed25519' }}
BURROW_SPARKLE_KMS_VERSION: ${{ vars.BURROW_SPARKLE_KMS_VERSION || '1' }}
BURROW_SPARKLE_FEED_URL: ${{ vars.BURROW_SPARKLE_FEED_URL || 'https://releases.burrow.net/sparkle/appcast.xml' }}
BURROW_SPARKLE_PUBLIC_ED_KEY: ${{ vars.BURROW_SPARKLE_PUBLIC_ED_KEY || 'Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=' }}
BURROW_VERSION_REQUIRE_REMOTE: "1"
jobs:
prepare:
name: Prepare
runs-on: namespace-profile-linux-medium
outputs:
do_release: ${{ steps.plan.outputs.do_release }}
build_number: ${{ steps.plan.outputs.build_number }}
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Fetch Build Tags
shell: bash
run: git fetch --force --prune origin "+refs/tags/builds/*:refs/tags/builds/*"
- name: Compute Release Plan
id: plan
shell: bash
env:
BURROW_BUILD_NUMBER_OVERRIDE: ${{ github.event.inputs.build_number_override || '' }}
run: |
set -euo pipefail
status="$(Scripts/version.sh status)"
if [[ "$status" == "clean" ]]; then
echo "do_release=false" >> "$GITHUB_OUTPUT"
echo "build_number=" >> "$GITHUB_OUTPUT"
echo "No unreleased changes."
exit 0
fi
build_number="$(Scripts/version.sh pre-increment)"
echo "do_release=true" >> "$GITHUB_OUTPUT"
echo "build_number=${build_number}" >> "$GITHUB_OUTPUT"
build-linux:
name: Build (Linux)
needs: prepare
if: ${{ needs.prepare.outputs.do_release == 'true' }}
runs-on: namespace-profile-linux-medium
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Build Linux Release
shell: bash
env:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
run: |
set -euo pipefail
bash Scripts/ci/ensure-nix.sh
nix develop .#ci -c Scripts/ci/nscloud-cache.sh nix
nix develop .#ci -c Scripts/ci/package-release-artifacts.sh linux
- name: Upload Linux Artifacts
uses: https://code.forgejo.org/actions/upload-artifact@v4
with:
name: burrow-linux-${{ needs.prepare.outputs.build_number }}
path: dist/builds/${{ needs.prepare.outputs.build_number }}/linux/*
if-no-files-found: error
- name: Upload Linux Artifacts To Release Storage
shell: bash
env:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
run: |
set -euo pipefail
bash Scripts/ci/ensure-nix.sh
nix develop .#ci -c Scripts/ci/upload-release-storage.sh
build-windows:
name: Build (Windows Stub)
needs: prepare
if: ${{ needs.prepare.outputs.do_release == 'true' }}
runs-on: namespace-profile-windows-large
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Build Rust Stub
shell: pwsh
env:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
run: |
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Invoke-WebRequest -Uri "https://win.rustup.rs" -OutFile "rustup-init.exe"
.\rustup-init.exe -y --profile minimal --default-toolchain stable
$env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path"
}
cargo build --locked --release -p burrow-windows-stub
New-Item -ItemType Directory -Force -Path "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc" | Out-Null
Copy-Item "target/release/burrow.exe" "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc/burrow.exe"
Copy-Item "README.md" "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc/README.md"
Compress-Archive -Path "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc" -DestinationPath "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc.zip" -Force
Get-FileHash "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc.zip" -Algorithm SHA256 | ForEach-Object { "$($_.Hash.ToLower()) burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc.zip" } | Set-Content "dist/builds/$env:BUILD_NUMBER/windows/burrow-$env:BUILD_NUMBER-x86_64-pc-windows-msvc.zip.sha256"
- name: Upload Windows Artifacts
uses: https://code.forgejo.org/actions/upload-artifact@v4
with:
name: burrow-windows-${{ needs.prepare.outputs.build_number }}
path: dist/builds/${{ needs.prepare.outputs.build_number }}/windows/*
if-no-files-found: error
build-apple:
name: Build (Apple ${{ matrix.platform_name }})
needs: prepare
if: ${{ needs.prepare.outputs.do_release == 'true' }}
runs-on: namespace-profile-macos-large
strategy:
fail-fast: false
max-parallel: 2
matrix:
include:
- platform: ios
platform_name: iOS
bazel_target: //bazel/apple:release_ios_stamp
- platform: macos
platform_name: macOS
bazel_target: //bazel/apple:release_macos_stamp
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
submodules: recursive
- name: Select Apple SDK
shell: bash
run: |
set -euo pipefail
if ! command -v xcrun >/dev/null 2>&1; then
echo "::error ::xcrun is required for Apple release builds" >&2
exit 1
fi
xcrun --find swiftc
xcrun --sdk macosx --show-sdk-path
xcrun --sdk iphoneos --show-sdk-path
xcrun --sdk iphonesimulator --show-sdk-path
- name: Bootstrap Runner Environment
shell: bash
run: |
set -euo pipefail
runner_home="${RUNNER_HOME:-}"
if [[ -z "$runner_home" ]]; then
runner_home="$(python3 -c 'import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)' 2>/dev/null || true)"
if [[ -z "$runner_home" ]]; then
runner_home="$PWD/.home"
fi
fi
mkdir -p "$runner_home" "$PWD/.tmp"
{
echo "HOME=$runner_home"
echo "XDG_CACHE_HOME=$runner_home/.cache"
echo "XDG_CONFIG_HOME=$runner_home/.config"
echo "XDG_DATA_HOME=$runner_home/.local/share"
echo "TMPDIR=$PWD/.tmp"
echo "TMP=$PWD/.tmp"
} >> "$GITHUB_ENV"
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Namespace Cache
shell: bash
run: |
set -euo pipefail
NIX_BIN="$(bash Scripts/ci/resolve-nix-bin.sh)"
"$NIX_BIN" develop .#ci --command bash Scripts/ci/nscloud-cache.sh bazel
- name: Configure Age
id: age_apple
continue-on-error: true
shell: bash
env:
AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }}
run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV"
- name: Decrypt Release Secrets
if: ${{ steps.age_apple.outcome == 'success' }}
uses: ./.forgejo/actions/decrypt-release-secrets
- name: Sync Apple Provisioning Profiles
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
run: |
set -euo pipefail
NIX_BIN="$(bash Scripts/ci/resolve-nix-bin.sh)"
"$NIX_BIN" develop .#ci --command Scripts/ci/sync-apple-provisioning-profiles.sh "$PLATFORM"
- name: Install Apple Signing Assets
shell: bash
run: Scripts/ci/install-apple-signing-assets.sh
- name: Build Apple Release
shell: bash
env:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
PLATFORM: ${{ matrix.platform }}
UPLOAD_APP_STORE: ${{ github.event.inputs.upload_app_store || 'false' }}
SPARKLE_CHANNEL: build-${{ needs.prepare.outputs.build_number }}
run: |
set -euo pipefail
require_signing=false
if [[ "$UPLOAD_APP_STORE" == "true" && "$PLATFORM" == "ios" ]]; then
require_signing=true
fi
bazel_args=()
if [[ -n "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" && -f "${BURROW_BAZEL_NSCCACHE_BAZELRC:-}" ]]; then
bazel_args+=("--bazelrc=${BURROW_BAZEL_NSCCACHE_BAZELRC}")
fi
NIX_BIN="$(bash Scripts/ci/resolve-nix-bin.sh)"
"$NIX_BIN" develop .#ci --command bazel "${bazel_args[@]}" build \
--verbose_failures \
--show_timestamps \
--action_env=BUILD_WORKSPACE_DIRECTORY="$PWD" \
--action_env=BUILD_NUMBER="$BUILD_NUMBER" \
--action_env=BURROW_RELEASE_OUT="$PWD/dist/builds/$BUILD_NUMBER" \
--action_env=BURROW_APPLE_SIGNING_READY="${BURROW_APPLE_SIGNING_READY:-0}" \
--action_env=BURROW_APPLE_REQUIRE_SIGNING="$require_signing" \
--action_env=BURROW_APPLE_BUNDLE_ID="${BURROW_APPLE_BUNDLE_ID:-}" \
--action_env=BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-}" \
--action_env=BURROW_APPLE_KEYCHAIN_PATH="${BURROW_APPLE_KEYCHAIN_PATH:-}" \
--action_env=BURROW_SPARKLE_SIGN_WITH_KMS="${BURROW_SPARKLE_SIGN_WITH_KMS:-false}" \
--action_env=BURROW_SPARKLE_KMS_KEY="${BURROW_SPARKLE_KMS_KEY:-sparkle-ed25519}" \
--action_env=BURROW_SPARKLE_KMS_VERSION="${BURROW_SPARKLE_KMS_VERSION:-1}" \
--action_env=BURROW_SPARKLE_FEED_URL="${BURROW_SPARKLE_FEED_URL:-https://releases.burrow.net/sparkle/appcast.xml}" \
--action_env=BURROW_SPARKLE_PUBLIC_ED_KEY="${BURROW_SPARKLE_PUBLIC_ED_KEY:-}" \
--action_env=GOOGLE_CLOUD_PROJECT="${GOOGLE_CLOUD_PROJECT:-}" \
--action_env=ASC_API_KEY_ID="${ASC_API_KEY_ID:-}" \
--action_env=ASC_API_ISSUER_ID="${ASC_API_ISSUER_ID:-}" \
--action_env=ASC_API_KEY_PATH="${ASC_API_KEY_PATH:-}" \
--action_env=SPARKLE_CHANNEL="$SPARKLE_CHANNEL" \
--action_env=HOME="$HOME" \
--action_env=XDG_CACHE_HOME="$XDG_CACHE_HOME" \
"${{ matrix.bazel_target }}"
- name: Upload Apple Artifacts
uses: https://code.forgejo.org/actions/upload-artifact@v4
with:
name: burrow-apple-${{ matrix.platform }}-${{ needs.prepare.outputs.build_number }}
path: |
dist/builds/${{ needs.prepare.outputs.build_number }}/apple/*
dist/builds/${{ needs.prepare.outputs.build_number }}/sparkle/**
if-no-files-found: error
- name: Upload Apple Artifacts To Release Storage
shell: bash
env:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
run: |
set -euo pipefail
bash Scripts/ci/ensure-nix.sh
nix develop .#ci -c Scripts/ci/upload-release-storage.sh
publish:
name: Publish (Forgejo)
needs:
- prepare
- build-linux
- build-windows
- build-apple
if: ${{ always() && needs.prepare.outputs.do_release == 'true' }}
runs-on: namespace-profile-linux-medium
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Download Artifacts
uses: https://code.forgejo.org/actions/download-artifact@v4
with:
path: dist/downloaded
- name: Prepare Release Assets
shell: bash
env:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
run: |
set -euo pipefail
mkdir -p dist
mkdir -p "dist/builds/${BUILD_NUMBER}/windows"
find dist/downloaded -path '*/burrow-windows-*/*' -type f -exec cp {} "dist/builds/${BUILD_NUMBER}/windows/" \;
find dist/downloaded -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.sha256' -o -name '*.ipa' -o -name '*.pkg' -o -name '*.dmg' -o -name 'README*.txt' \) -exec cp {} dist/ \;
rm -rf dist/downloaded
find dist -maxdepth 1 -type f -print | sort
- name: Upload Windows Artifacts To Release Storage
shell: bash
env:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
run: |
set -euo pipefail
if ! find "dist/builds/${BUILD_NUMBER}/windows" -type f -print -quit | grep -q .; then
echo "::warning ::No Windows artifacts staged for release storage upload."
exit 0
fi
bash Scripts/ci/ensure-nix.sh
nix develop .#ci -c Scripts/ci/upload-release-storage.sh
- name: Tag Build
shell: bash
env:
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
run: |
set -euo pipefail
tag="builds/${BUILD_NUMBER}"
head_commit="$(git rev-parse HEAD)"
remote_commit="$(git ls-remote --tags --refs origin "refs/tags/${tag}" | awk 'NR==1 { print $1 }')"
if [[ -n "$remote_commit" && "$remote_commit" != "$head_commit" ]]; then
echo "::error ::Remote tag ${tag} already points at ${remote_commit}, not ${head_commit}" >&2
exit 1
fi
git tag -f "$tag" "$head_commit"
git push --quiet --no-verify origin "refs/tags/${tag}:refs/tags/${tag}"
- name: Publish Forgejo Release
shell: bash
env:
RELEASE_TAG: builds/${{ needs.prepare.outputs.build_number }}
API_URL: ${{ github.api_url }}
REPOSITORY: ${{ github.repository }}
TOKEN: ${{ github.token }}
run: Scripts/ci/publish-forgejo-release.sh
- name: Dispatch Store Uploads
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }}
UPLOAD_APP_STORE: ${{ github.event.inputs.upload_app_store || 'false' }}
UPLOAD_SPARKLE: ${{ github.event.inputs.upload_sparkle || 'true' }}
UPLOAD_MICROSOFT_STORE: ${{ github.event.inputs.upload_microsoft_store || 'false' }}
SPARKLE_POINT_MAIN: ${{ github.event.inputs.sparkle_point_main || 'true' }}
TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ github.event.inputs.testflight_distribute_external || 'false' }}
TESTFLIGHT_GROUPS: ${{ github.event.inputs.testflight_groups || '' }}
IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ github.event.inputs.ios_uses_non_exempt_encryption || 'false' }}
run: |
set -euo pipefail
inputs="$(python3 -c 'import json, os; print(json.dumps({"build_number":os.environ["BUILD_NUMBER"],"upload_app_store":os.environ["UPLOAD_APP_STORE"],"upload_sparkle":os.environ["UPLOAD_SPARKLE"],"upload_microsoft_store":os.environ["UPLOAD_MICROSOFT_STORE"],"sparkle_point_main":os.environ["SPARKLE_POINT_MAIN"],"testflight_distribute_external":os.environ["TESTFLIGHT_DISTRIBUTE_EXTERNAL"],"testflight_groups":os.environ["TESTFLIGHT_GROUPS"],"ios_uses_non_exempt_encryption":os.environ["IOS_USES_NON_EXEMPT_ENCRYPTION"]}))')"
curl -fsS \
-X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\":\"main\",\"inputs\":${inputs}}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/publish-store-uploads.yml/dispatches"

View file

@ -0,0 +1,237 @@
name: "Infra: OpenTofu"
run-name: "Infra: OpenTofu (${{ github.event.inputs.stack || 'grafana' }} ${{ github.event.inputs.mode || 'validate' }})"
on:
push:
branches:
- main
paths:
- ".forgejo/workflows/infra-opentofu.yml"
- "Scripts/grafana-tofu.sh"
- "Scripts/identity-tofu.sh"
- "Scripts/openbao-tofu.sh"
- "Scripts/releases-tofu.sh"
- "infra/grafana/**"
- "infra/identity/**"
- "infra/openbao/**"
- "infra/releases/**"
- "services/grafana/**"
workflow_dispatch:
inputs:
stack:
description: OpenTofu stack to run
required: true
default: grafana
type: choice
options:
- grafana
- identity
- openbao
- releases
mode:
description: OpenTofu mode
required: true
default: plan
type: choice
options:
- fmt
- validate
- plan
- apply
- import
apply_confirmation:
description: Type apply-burrow-<stack>-tofu to run apply
required: false
default: ""
import_address:
description: OpenTofu resource address for import mode
required: false
default: ""
import_id:
description: Provider import id for import mode
required: false
default: ""
permissions:
contents: read
concurrency:
group: infra-opentofu-${{ github.ref }}-${{ github.event.inputs.stack || 'grafana' }}
cancel-in-progress: false
jobs:
opentofu:
name: OpenTofu (${{ github.event.inputs.stack || 'grafana' }})
runs-on: [self-hosted, linux, x86_64, burrow-forge]
timeout-minutes: 30
env:
MODE: ${{ github.event.inputs.mode || 'validate' }}
STACK: ${{ github.event.inputs.stack || 'grafana' }}
APPLY_CONFIRMATION: ${{ github.event.inputs.apply_confirmation || '' }}
IMPORT_ADDRESS: ${{ github.event.inputs.import_address || '' }}
IMPORT_ID: ${{ github.event.inputs.import_id || '' }}
TF_INPUT: "false"
TF_IN_AUTOMATION: "true"
TF_VAR_grafana_url: ${{ vars.GRAFANA_URL || 'https://graphs.burrow.net' }}
TF_VAR_google_project_id: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
TF_VAR_google_project_number: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }}
TF_VAR_google_runner_service_account_email: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }}
TF_VAR_google_wif_runner_client_id: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }}
TF_VAR_google_release_bucket_name: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }}
TF_VAR_google_package_bucket_name: ${{ vars.BURROW_PACKAGE_GCS_BUCKET || 'burrow-net-packages' }}
TF_VAR_google_nix_cache_bucket_name: ${{ vars.BURROW_NIX_CACHE_GCS_BUCKET || 'burrow-net-nix-cache' }}
TF_VAR_google_nix_cache_public_read: ${{ vars.BURROW_NIX_CACHE_GCS_PUBLIC_READ || 'false' }}
TF_VAR_google_storage_location: ${{ vars.BURROW_GCS_LOCATION || 'US' }}
BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }}
BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }}
BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }}
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }}
BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }}
BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }}
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
OPENTOFU_STATE_BUCKET: ${{ vars.OPENTOFU_STATE_BUCKET || '' }}
OPENTOFU_STATE_REGION: ${{ vars.OPENTOFU_STATE_REGION || vars.AWS_REGION || 'us-west-2' }}
OPENTOFU_STATE_DYNAMODB_TABLE: ${{ vars.OPENTOFU_STATE_DYNAMODB_TABLE || '' }}
steps:
- name: Checkout
shell: bash
run: |
set -euo pipefail
repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${repo_url}"
else
git remote add origin "${repo_url}"
fi
git fetch --force --tags origin "${GITHUB_SHA}"
git checkout --force --detach FETCH_HEAD
git clean -ffdqx
- name: Validate Request
shell: bash
run: |
set -euo pipefail
case "$STACK" in
grafana|identity|openbao|releases) ;;
*)
echo "::error ::Unsupported OpenTofu stack: $STACK" >&2
exit 2
;;
esac
case "$MODE" in
fmt|validate|plan|apply|import) ;;
*)
echo "::error ::Unsupported OpenTofu mode: $MODE" >&2
exit 2
;;
esac
if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" && "$MODE" != "validate" ]]; then
echo "::error ::Non-manual runs may only validate." >&2
exit 2
fi
if [[ "$MODE" == "apply" && "$APPLY_CONFIRMATION" != "apply-burrow-${STACK}-tofu" ]]; then
echo "::error ::apply_confirmation must be exactly apply-burrow-${STACK}-tofu." >&2
exit 1
fi
if [[ "$MODE" == "import" && ( -z "$IMPORT_ADDRESS" || -z "$IMPORT_ID" ) ]]; then
echo "::error ::import mode requires import_address and import_id." >&2
exit 2
fi
- name: Configure Age
if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'import') }}
shell: bash
env:
AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }}
run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV"
- name: Configure Grafana Provider Auth
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.stack == 'grafana' && (github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'import') }}
shell: bash
run: |
set -euo pipefail
: "${AGE_IDENTITY_PATH:?AGE_IDENTITY_PATH is required}"
agenix="$(nix build --no-link .#agenix --print-out-paths | tail -n1)/bin/agenix"
password="$("$agenix" --identity "$AGE_IDENTITY_PATH" --decrypt secrets/infra/grafana-admin-password.age | tr -d '\r')"
echo "::add-mask::${password}"
{
echo "TF_VAR_grafana_auth<<EOF"
printf 'admin:%s\n' "$password"
echo "EOF"
} >> "$GITHUB_ENV"
- name: Configure OpenTofu Backend
shell: bash
run: |
set -euo pipefail
stack_dir="infra/${STACK}"
rm -f "${stack_dir}/backend.hcl"
if [[ -n "$OPENTOFU_STATE_BUCKET" ]]; then
{
printf 'bucket = "%s"\n' "$OPENTOFU_STATE_BUCKET"
printf 'key = "%s"\n' "${STACK}/${STACK}.tfstate"
printf 'region = "%s"\n' "$OPENTOFU_STATE_REGION"
printf 'encrypt = true\n'
if [[ -n "$OPENTOFU_STATE_DYNAMODB_TABLE" ]]; then
printf 'dynamodb_table = "%s"\n' "$OPENTOFU_STATE_DYNAMODB_TABLE"
fi
} > "${stack_dir}/backend.hcl"
elif [[ "$MODE" == "apply" || "$MODE" == "import" ]]; then
echo "::error ::OPENTOFU_STATE_BUCKET is required for apply/import." >&2
exit 1
fi
- name: Authenticate Google WIF
if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.stack == 'identity' || github.event.inputs.stack == 'releases') && (github.event.inputs.mode == 'plan' || github.event.inputs.mode == 'apply' || github.event.inputs.mode == 'import') }}
shell: bash
run: |
set -euo pipefail
nix develop .#ci -c Scripts/ci/google-wif-auth.sh
- name: Run OpenTofu
shell: bash
run: |
set -euo pipefail
stack_script="Scripts/${STACK}-tofu.sh"
case "$MODE" in
fmt)
nix develop .#ci -c tofu -chdir="infra/${STACK}" fmt -check
;;
validate)
"$stack_script" init -backend=false
"$stack_script" validate
;;
plan)
if [[ -f "infra/${STACK}/backend.hcl" ]]; then
"$stack_script" init -backend-config=backend.hcl
else
"$stack_script" init -backend=false
fi
"$stack_script" plan
;;
apply)
"$stack_script" init -backend-config=backend.hcl
"$stack_script" apply -auto-approve
;;
import)
"$stack_script" init -backend-config=backend.hcl
"$stack_script" import "$IMPORT_ADDRESS" "$IMPORT_ID"
;;
esac

View file

@ -0,0 +1,63 @@
name: "Cache: Publish Nix"
run-name: "Publish Nix Cache (${{ github.ref_name }})"
on:
push:
branches:
- main
paths:
- ".forgejo/workflows/publish-nix-cache.yml"
- "Cargo.lock"
- "Cargo.toml"
- "flake.lock"
- "flake.nix"
- "crates/**"
- "services/forgejo-nsc/**"
- "nixos/**"
- "Scripts/ci/ensure-nix.sh"
- "Scripts/ci/publish-nix-cache.sh"
workflow_dispatch:
inputs:
attrs:
description: Space-separated flake attrs to build and push
required: false
default: ".#burrow .#forgejo-nsc-dispatcher .#forgejo-nsc-autoscaler"
required:
description: Fail when the cache push token is missing
required: false
default: "false"
permissions:
contents: read
concurrency:
group: publish-nix-cache-${{ github.ref }}
cancel-in-progress: true
jobs:
publish:
name: Publish Nix Cache
runs-on: namespace-profile-linux-medium
timeout-minutes: 45
env:
BURROW_NIX_CACHE_SERVER_NAME: ${{ vars.BURROW_NIX_CACHE_SERVER_NAME || 'burrow' }}
BURROW_NIX_CACHE_SERVER_URL: ${{ vars.BURROW_NIX_CACHE_SERVER_URL || 'https://nix.burrow.net' }}
BURROW_NIX_CACHE_NAME: ${{ vars.BURROW_NIX_CACHE_NAME || 'burrow' }}
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
BURROW_NIX_CACHE_PUSH_TOKEN: ${{ secrets.BURROW_NIX_CACHE_PUSH_TOKEN }}
BURROW_NIX_CACHE_ATTRS: ${{ github.event.inputs.attrs || '.#burrow .#forgejo-nsc-dispatcher .#forgejo-nsc-autoscaler' }}
BURROW_NIX_CACHE_REQUIRED: ${{ github.event.inputs.required || 'false' }}
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Publish Cache Paths
shell: bash
run: nix develop .#ci -c Scripts/ci/publish-nix-cache.sh

View file

@ -0,0 +1,148 @@
name: "Publish: Package Repositories"
run-name: "Publish Package Repositories (${{ github.event.inputs.channel || 'stable' }})"
on:
workflow_dispatch:
inputs:
build_number:
description: Build number or release label for package artifact paths
required: false
default: ""
channel:
description: Native package repository channel
required: true
default: stable
type: choice
options:
- stable
- beta
- nightly
publish_gcs:
description: Publish repository tree to configured package storage targets
required: false
default: "true"
permissions:
contents: read
concurrency:
group: publish-package-repositories-${{ github.ref }}-${{ github.event.inputs.channel || 'stable' }}
cancel-in-progress: false
jobs:
publish:
name: Build Native Repositories
runs-on: [self-hosted, linux, x86_64, burrow-forge]
timeout-minutes: 45
env:
BUILD_NUMBER: ${{ github.event.inputs.build_number || github.sha }}
BURROW_PACKAGE_CHANNEL: ${{ github.event.inputs.channel || 'stable' }}
BURROW_KMS_LOCATION: ${{ vars.BURROW_KMS_LOCATION || 'global' }}
BURROW_KMS_KEYRING: ${{ vars.BURROW_KMS_KEYRING || 'burrow-identity' }}
BURROW_KMS_VERSION: ${{ vars.BURROW_KMS_VERSION || '1' }}
BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }}
BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }}
BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }}
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }}
BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }}
BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }}
BURROW_PACKAGE_GCS_BUCKET: ${{ vars.BURROW_PACKAGE_GCS_BUCKET || 'burrow-net-packages' }}
BURROW_PACKAGE_GCS_PREFIX: ${{ vars.BURROW_PACKAGE_GCS_PREFIX || '' }}
BURROW_PACKAGE_GCS_BACKUP: ${{ vars.BURROW_PACKAGE_GCS_BACKUP || 'true' }}
BURROW_PACKAGE_GARAGE_BUCKET: ${{ vars.BURROW_PACKAGE_GARAGE_BUCKET || 'burrow-packages' }}
BURROW_PACKAGE_GARAGE_PREFIX: ${{ vars.BURROW_PACKAGE_GARAGE_PREFIX || vars.BURROW_PACKAGE_GCS_PREFIX || '' }}
BURROW_PACKAGE_REQUIRE_GARAGE: ${{ vars.BURROW_PACKAGE_REQUIRE_GARAGE || 'true' }}
BURROW_GARAGE_ENDPOINT: ${{ vars.BURROW_GARAGE_ENDPOINT || 'https://objects.burrow.net' }}
BURROW_GARAGE_REGION: ${{ vars.BURROW_GARAGE_REGION || 'garage' }}
AWS_ACCESS_KEY_ID: ${{ secrets.BURROW_GARAGE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.BURROW_GARAGE_SECRET_ACCESS_KEY }}
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
steps:
- name: Checkout
shell: bash
run: |
set -euo pipefail
repo_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${repo_url}"
else
git remote add origin "${repo_url}"
fi
git fetch --force --tags origin "${GITHUB_SHA}"
git checkout --force --detach FETCH_HEAD
git clean -ffdqx
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Build Linux Packages
shell: bash
run: |
set -euo pipefail
export BURROW_RELEASE_OUT="$PWD/dist/builds/$BUILD_NUMBER"
nix develop .#ci -c Scripts/ci/package-release-artifacts.sh linux
- name: Authenticate Google WIF
shell: bash
run: |
set -euo pipefail
nix develop .#ci -c Scripts/ci/google-wif-auth.sh
- name: Export KMS Public Keys
shell: bash
run: |
set -euo pipefail
mkdir -p "$PWD/.kms"
export BURROW_APT_REPO_KMS_KEY="${BURROW_APT_REPO_KMS_KEY:-package-apt-repository}"
export BURROW_RPM_REPO_KMS_KEY="${BURROW_RPM_REPO_KMS_KEY:-package-rpm-repository}"
export BURROW_PACMAN_REPO_KMS_KEY="${BURROW_PACMAN_REPO_KMS_KEY:-package-pacman-repository}"
for item in \
"APT:${BURROW_APT_REPO_KMS_KEY}:apt" \
"RPM:${BURROW_RPM_REPO_KMS_KEY}:rpm" \
"PACMAN:${BURROW_PACMAN_REPO_KMS_KEY}:pacman"
do
IFS=: read -r env_prefix key_name file_prefix <<< "$item"
pem="$PWD/.kms/${file_prefix}.pem"
nix develop .#ci -c gcloud kms keys versions get-public-key "$BURROW_KMS_VERSION" \
--location "$BURROW_KMS_LOCATION" \
--keyring "$BURROW_KMS_KEYRING" \
--key "$key_name" \
--project "$GOOGLE_CLOUD_PROJECT" \
--output-file "$pem"
echo "BURROW_${env_prefix}_REPO_KMS_PUBLIC_KEY_PEM=$pem" >> "$GITHUB_ENV"
echo "BURROW_${env_prefix}_REPO_KMS_KEY=$key_name" >> "$GITHUB_ENV"
done
- name: Build Repository Metadata
shell: bash
run: |
set -euo pipefail
export BURROW_PACKAGE_INPUT_DIR="$PWD/dist/builds/$BUILD_NUMBER/linux"
export BURROW_PACKAGE_REPOSITORY_DIR="$PWD/dist/builds/$BUILD_NUMBER/repositories"
nix develop .#ci -c Scripts/package/build-repositories.sh all
- name: Upload Repository Artifact
uses: https://code.forgejo.org/actions/upload-artifact@v4
with:
name: burrow-package-repositories-${{ github.event.inputs.channel || 'stable' }}-${{ github.event.inputs.build_number || github.sha }}
path: dist/builds/${{ github.event.inputs.build_number || github.sha }}/repositories/**
if-no-files-found: error
- name: Publish Package Repository To Storage
if: ${{ github.event.inputs.publish_gcs == 'true' }}
shell: bash
run: |
set -euo pipefail
export BURROW_PACKAGE_REPOSITORY_DIR="$PWD/dist/builds/$BUILD_NUMBER/repositories"
nix develop .#ci -c Scripts/ci/upload-package-storage.sh

View file

@ -0,0 +1,299 @@
name: "Publish: Release Uploads"
run-name: "Publish: Release Uploads (Build ${{ inputs.build_number }})"
on:
workflow_dispatch:
inputs:
build_number:
description: Build number to publish
required: true
upload_app_store:
description: Upload iOS/macOS/visionOS artifacts to App Store Connect
required: false
default: "false"
upload_sparkle:
description: Publish Sparkle artifacts
required: false
default: "true"
upload_microsoft_store:
description: Upload Windows package to Microsoft Partner Center beta lane
required: false
default: "false"
sparkle_channel:
description: Sparkle channel, defaults to build-<number>
required: false
default: ""
sparkle_point_main:
description: Point Sparkle main appcast/default channel to selected channel
required: false
default: "true"
testflight_distribute_external:
description: Distribute to external TestFlight groups
required: false
default: "false"
testflight_groups:
description: Optional comma-separated TestFlight groups for external distribution
required: false
default: ""
ios_uses_non_exempt_encryption:
description: Set true only when the iOS build uses non-exempt encryption
required: false
default: "false"
permissions:
contents: read
concurrency:
group: burrow-publish-store-uploads-${{ github.event.inputs.build_number }}
cancel-in-progress: true
env:
BURROW_RELEASE_GCS_BUCKET: ${{ vars.BURROW_RELEASE_GCS_BUCKET || 'burrow-net-releases' }}
BURROW_GOOGLE_PROJECT_ID: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
GOOGLE_CLOUD_PROJECT: ${{ vars.GOOGLE_PROJECT_ID || 'project-88c23ce9-918a-470a-b33' }}
BURROW_GOOGLE_PROJECT_NUMBER: ${{ vars.GOOGLE_PROJECT_NUMBER || '416198671487' }}
BURROW_GOOGLE_WIF_POOL_ID: ${{ vars.BURROW_GOOGLE_WIF_POOL_ID || 'burrow-authentik' }}
BURROW_GOOGLE_WIF_PROVIDER_ID: ${{ vars.BURROW_GOOGLE_WIF_PROVIDER_ID || 'forgejo-runners' }}
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT: ${{ vars.BURROW_GOOGLE_WIF_SERVICE_ACCOUNT || 'burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com' }}
BURROW_AUTHENTIK_WIF_ISSUER: ${{ vars.BURROW_AUTHENTIK_WIF_ISSUER || 'https://auth.burrow.net/application/o/google-cloud/' }}
BURROW_AUTHENTIK_WIF_AUDIENCE: ${{ vars.BURROW_AUTHENTIK_WIF_AUDIENCE || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_ID: ${{ vars.BURROW_AUTHENTIK_WIF_CLIENT_ID || 'google-wif.burrow.net' }}
BURROW_AUTHENTIK_WIF_CLIENT_SECRET: ${{ secrets.BURROW_AUTHENTIK_WIF_CLIENT_SECRET }}
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
BURROW_APPLE_BUNDLE_ID: ${{ vars.BURROW_APPLE_BUNDLE_ID || 'net.burrow.app' }}
jobs:
upload-app-store:
name: Upload (App Store Connect)
if: ${{ github.event.inputs.upload_app_store == 'true' }}
runs-on: namespace-profile-macos-large
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Configure Age
shell: bash
env:
AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }}
run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV"
- name: Decrypt Release Secrets
uses: ./.forgejo/actions/decrypt-release-secrets
- name: Validate App Store Credentials
shell: bash
run: Scripts/ci/check-release-config.sh app-store
- name: Download Apple Artifacts From GCS
shell: bash
env:
BUILD_NUMBER: ${{ github.event.inputs.build_number }}
run: |
set -euo pipefail
nix develop .#ci -c Scripts/ci/google-wif-auth.sh
mkdir -p publish/apple
nix develop .#ci -c gcloud storage rsync --recursive "gs://${BURROW_RELEASE_GCS_BUCKET}/builds/${BUILD_NUMBER}/apple" publish/apple
find publish/apple -type f -print | sort
- name: Upload Apple Packages
shell: bash
run: |
set -euo pipefail
Scripts/ci/check-release-config.sh app-store
key_dir="${RUNNER_TEMP:-/tmp}/burrow-asc"
altool_key_dir="${HOME}/.appstoreconnect/private_keys"
mkdir -p "$key_dir" "$altool_key_dir"
key_path="$key_dir/AuthKey_${ASC_API_KEY_ID}.p8"
if [[ -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then
cp "$ASC_API_KEY_PATH" "$key_path"
else
printf '%s' "$ASC_API_KEY_BASE64" | base64 --decode > "$key_path"
fi
cp "$key_path" "$altool_key_dir/AuthKey_${ASC_API_KEY_ID}.p8"
chmod 600 "$key_path" "$altool_key_dir/AuthKey_${ASC_API_KEY_ID}.p8"
shopt -s nullglob
artifacts=(publish/apple/*.ipa publish/apple/*.pkg)
if (( ${#artifacts[@]} == 0 )); then
echo "::error ::No Apple upload artifacts found for build ${{ github.event.inputs.build_number }}."
exit 1
fi
for artifact in "${artifacts[@]}"; do
upload_type="ios"
if [[ "$artifact" == *.pkg ]]; then
upload_type="macos"
fi
xcrun altool --upload-app \
--type "$upload_type" \
--file "$artifact" \
--apiKey "$ASC_API_KEY_ID" \
--apiIssuer "$ASC_API_ISSUER_ID"
done
release-ios-testflight:
name: Release (TestFlight iOS Testers)
needs: upload-app-store
if: ${{ github.event.inputs.upload_app_store == 'true' }}
runs-on: namespace-profile-linux-medium
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Configure Age
shell: bash
env:
AGE_FORGE_SSH_KEY: ${{ secrets.AGE_FORGE_SSH_KEY }}
run: Scripts/resolve-age-identity.sh >> "$GITHUB_ENV"
- name: Decrypt Release Secrets
uses: ./.forgejo/actions/decrypt-release-secrets
- name: Validate App Store Credentials
shell: bash
run: Scripts/ci/check-release-config.sh app-store
- name: Distribute iOS Build To TestFlight
shell: bash
env:
BUILD_NUMBER: ${{ github.event.inputs.build_number }}
TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ github.event.inputs.testflight_distribute_external || 'false' }}
TESTFLIGHT_GROUPS: ${{ github.event.inputs.testflight_groups || '' }}
TESTFLIGHT_INTERNAL_GROUP: Internal
IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ github.event.inputs.ios_uses_non_exempt_encryption || 'false' }}
run: |
set -euo pipefail
Scripts/ci/check-release-config.sh app-store
api_key_json="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-appstore-api-key.XXXXXX.json")"
key_file="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-appstore-key.XXXXXX.p8")"
if [[ -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then
cp "$ASC_API_KEY_PATH" "$key_file"
else
printf '%s' "$ASC_API_KEY_BASE64" | base64 --decode > "$key_file"
fi
KEY_FILE="$key_file" python3 -c 'import json, os, pathlib; print(json.dumps({"key_id": os.environ["ASC_API_KEY_ID"], "issuer_id": os.environ["ASC_API_ISSUER_ID"], "key": pathlib.Path(os.environ["KEY_FILE"]).read_text(), "duration": 1200, "in_house": False}))' > "$api_key_json"
distribute_external="${TESTFLIGHT_DISTRIBUTE_EXTERNAL:-false}"
if [[ -z "$distribute_external" ]]; then
distribute_external="false"
fi
uses_non_exempt="${IOS_USES_NON_EXEMPT_ENCRYPTION:-false}"
if [[ -z "$uses_non_exempt" ]]; then
uses_non_exempt="false"
fi
args=(
run pilot
"api_key_path:${api_key_json}"
"app_identifier:${BURROW_APPLE_BUNDLE_ID:-net.burrow.app}"
"app_platform:ios"
"distribute_only:true"
"build_number:${BUILD_NUMBER}"
"wait_processing_interval:30"
"wait_processing_timeout_duration:1800"
"distribute_external:${distribute_external}"
"uses_non_exempt_encryption:${uses_non_exempt}"
)
if [[ -n "${TESTFLIGHT_GROUPS}" ]]; then
args+=("groups:${TESTFLIGHT_GROUPS}")
elif [[ "$distribute_external" != "true" && -n "${TESTFLIGHT_INTERNAL_GROUP:-}" ]]; then
args+=("groups:${TESTFLIGHT_INTERNAL_GROUP}")
fi
nix run nixpkgs#fastlane -- "${args[@]}"
rm -f "$api_key_json" "$key_file"
upload-sparkle:
name: Upload (Sparkle)
if: ${{ github.event.inputs.upload_sparkle == 'true' }}
runs-on: namespace-profile-linux-medium
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Resolve Sparkle Channel
id: channel
shell: bash
env:
BUILD_NUMBER: ${{ github.event.inputs.build_number }}
run: |
set -euo pipefail
channel="${{ github.event.inputs.sparkle_channel }}"
if [[ -z "$channel" ]]; then
channel="build-${BUILD_NUMBER}"
fi
echo "channel=$channel" >> "$GITHUB_OUTPUT"
- name: Ensure Nix
shell: bash
run: bash Scripts/ci/ensure-nix.sh
- name: Publish Sparkle Channel
shell: bash
env:
BUILD_NUMBER: ${{ github.event.inputs.build_number }}
CHANNEL: ${{ steps.channel.outputs.channel }}
SPARKLE_POINT_MAIN: ${{ github.event.inputs.sparkle_point_main }}
run: |
set -euo pipefail
nix develop .#ci -c Scripts/ci/google-wif-auth.sh
mkdir -p "publish/sparkle/${CHANNEL}"
nix develop .#ci -c gcloud storage rsync --recursive "gs://${BURROW_RELEASE_GCS_BUCKET}/builds/${BUILD_NUMBER}/sparkle/${CHANNEL}" "publish/sparkle/${CHANNEL}" || true
if [[ ! -f "publish/sparkle/${CHANNEL}/appcast.xml" ]]; then
echo "::warning ::No Sparkle appcast staged for build ${BUILD_NUMBER}; skipping Sparkle publish."
find "publish/sparkle/${CHANNEL}" -maxdepth 2 -type f -print || true
exit 0
fi
nix develop .#ci -c gcloud storage rsync --recursive "publish/sparkle/${CHANNEL}" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/${CHANNEL}"
if [[ "$SPARKLE_POINT_MAIN" == "true" ]]; then
printf '%s\n' "$CHANNEL" > main-channel.txt
nix develop .#ci -c gcloud storage cp main-channel.txt "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/main-channel.txt"
nix develop .#ci -c gcloud storage cp "publish/sparkle/${CHANNEL}/appcast.xml" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/appcast.xml"
nix develop .#ci -c gcloud storage rsync --recursive "publish/sparkle/${CHANNEL}" "gs://${BURROW_RELEASE_GCS_BUCKET}/sparkle/default"
fi
upload-microsoft-store:
name: Upload (Microsoft Store Beta)
if: ${{ github.event.inputs.upload_microsoft_store == 'true' }}
runs-on: namespace-profile-windows-large
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Validate Microsoft Store Credentials
shell: pwsh
env:
MICROSOFT_TENANT_ID: ${{ secrets.MICROSOFT_TENANT_ID }}
MICROSOFT_CLIENT_ID: ${{ secrets.MICROSOFT_CLIENT_ID }}
MICROSOFT_CLIENT_SECRET: ${{ secrets.MICROSOFT_CLIENT_SECRET }}
run: |
foreach ($name in "MICROSOFT_TENANT_ID","MICROSOFT_CLIENT_ID","MICROSOFT_CLIENT_SECRET") {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "missing required environment variable: $name"
}
}
- name: Stop Until Store Package Exists
shell: pwsh
run: |
Write-Host "::warning ::Burrow only builds a Windows Rust stub today; Microsoft Store package upload is wired for credentials but waits on MSIX packaging."

View file

@ -0,0 +1,89 @@
name: "Release: If Needed"
run-name: "Release: If Needed (${{ github.ref_name }})"
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
inputs:
upload_app_store:
description: Trigger downstream App Store Connect upload workflow
required: false
default: "false"
upload_sparkle:
description: Trigger downstream Sparkle publish workflow
required: false
default: "true"
upload_microsoft_store:
description: Trigger downstream Microsoft Store beta upload workflow
required: false
default: "false"
sparkle_point_main:
description: Update Sparkle main channel pointers
required: false
default: "true"
testflight_distribute_external:
description: Distribute to external TestFlight groups
required: false
default: "false"
testflight_groups:
description: Optional comma-separated TestFlight groups for external distribution
required: false
default: ""
ios_uses_non_exempt_encryption:
description: Set true only when the iOS build uses non-exempt encryption
required: false
default: "false"
permissions:
contents: read
actions: write
concurrency:
group: burrow-release-if-needed-main
cancel-in-progress: true
env:
GITHUB_TOKEN: ${{ github.token }}
BURROW_VERSION_REQUIRE_REMOTE: "1"
jobs:
check:
name: Check (Release Needed)
runs-on: namespace-profile-linux-medium
steps:
- name: Checkout
uses: https://code.forgejo.org/actions/checkout@v4
with:
token: ${{ github.token }}
fetch-depth: 0
- name: Fetch Build Tags
shell: bash
run: git fetch --force --prune origin "+refs/tags/builds/*:refs/tags/builds/*"
- name: Dispatch Release Build
shell: bash
env:
UPLOAD_APP_STORE: ${{ github.event.inputs.upload_app_store || 'false' }}
UPLOAD_SPARKLE: ${{ github.event.inputs.upload_sparkle || 'true' }}
UPLOAD_MICROSOFT_STORE: ${{ github.event.inputs.upload_microsoft_store || 'false' }}
SPARKLE_POINT_MAIN: ${{ github.event.inputs.sparkle_point_main || 'true' }}
TESTFLIGHT_DISTRIBUTE_EXTERNAL: ${{ github.event.inputs.testflight_distribute_external || 'false' }}
TESTFLIGHT_GROUPS: ${{ github.event.inputs.testflight_groups || '' }}
IOS_USES_NON_EXEMPT_ENCRYPTION: ${{ github.event.inputs.ios_uses_non_exempt_encryption || 'false' }}
run: |
set -euo pipefail
status="$(Scripts/version.sh status)"
if [[ "$status" == "clean" ]]; then
echo "No unreleased changes."
exit 0
fi
inputs="$(python3 -c 'import json, os; print(json.dumps({"upload_app_store":os.environ["UPLOAD_APP_STORE"],"upload_sparkle":os.environ["UPLOAD_SPARKLE"],"upload_microsoft_store":os.environ["UPLOAD_MICROSOFT_STORE"],"sparkle_point_main":os.environ["SPARKLE_POINT_MAIN"],"testflight_distribute_external":os.environ["TESTFLIGHT_DISTRIBUTE_EXTERNAL"],"testflight_groups":os.environ["TESTFLIGHT_GROUPS"],"ios_uses_non_exempt_encryption":os.environ["IOS_USES_NON_EXEMPT_ENCRYPTION"]}))')"
curl -fsS \
-X POST \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\":\"main\",\"inputs\":${inputs}}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/build-release.yml/dispatches"
echo "Dispatched build-release.yml (status=${status})."

View file

@ -10,6 +10,10 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
env:
BURROW_NIX_CACHE_URL: ${{ vars.BURROW_NIX_CACHE_URL || 'https://nix.burrow.net/burrow' }}
BURROW_NIX_CACHE_PUBLIC_KEY: ${{ vars.BURROW_NIX_CACHE_PUBLIC_KEY }}
jobs: jobs:
release: release:
name: Release Build name: Release Build
@ -39,6 +43,19 @@ jobs:
chmod +x Scripts/ci/build-release-artifacts.sh chmod +x Scripts/ci/build-release-artifacts.sh
nix develop .#ci -c Scripts/ci/build-release-artifacts.sh nix develop .#ci -c Scripts/ci/build-release-artifacts.sh
- name: Flatten release assets
shell: bash
env:
RELEASE_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
ref="${RELEASE_REF:-manual-${GITHUB_SHA::7}}"
mkdir -p dist/release-assets
find "dist/builds/${ref}" -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.sha256' -o -name 'README.txt' \) -exec cp {} dist/release-assets/ \;
rm -rf dist/builds
cp dist/release-assets/* dist/
find dist -maxdepth 1 -type f -print | sort
- name: Upload release artifacts - name: Upload release artifacts
uses: https://code.forgejo.org/actions/upload-artifact@v4 uses: https://code.forgejo.org/actions/upload-artifact@v4
with: with:

12
.gitignore vendored
View file

@ -1,6 +1,10 @@
# Xcode # Xcode
xcuserdata xcuserdata
Apple/build/ Apple/build/
.derived-data/
AuthKey_*.p8
burrow-certs-pass.txt
*.p12
# Swift # Swift
Apple/Package/.swiftpm/ Apple/Package/.swiftpm/
@ -9,11 +13,19 @@ Apple/Package/.swiftpm/
target/ target/
.env .env
# Bazel
bazel-*
.DS_STORE .DS_STORE
.idea/ .idea/
tmp/ tmp/
.cache/
intake/ intake/
dist/
publish/
release/
ci-artifacts/
*.db *.db
*.sqlite3 *.sqlite3

14
Android/README.md Normal file
View file

@ -0,0 +1,14 @@
# Burrow Android
The Android app starts as a Kotlin shell linked to `crates/burrow-mobile-ffi`,
which exposes the cross-platform `burrow-core` crate through JNI.
The current stub proves the package shape and Rust core boundary. Full VPN
support must use Android `VpnService` and keep platform consent, Play Store
policy, and disclosure requirements in the release lane.
The Bazel entrypoint is:
```sh
bazel build //bazel/android:check_stub_stamp
```

View file

@ -0,0 +1,21 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "net.burrow.android"
compileSdk = 36
defaultConfig {
applicationId = "net.burrow.android"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
}
kotlin {
jvmToolchain(17)
}

View file

@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/BurrowTheme">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,9 @@
package net.burrow.android
object BurrowCore {
init {
System.loadLibrary("burrow_mobile_ffi")
}
external fun version(): String
}

View file

@ -0,0 +1,32 @@
package net.burrow.android
import android.app.Activity
import android.os.Bundle
import android.widget.LinearLayout
import android.widget.TextView
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val versionText = runCatching { BurrowCore.version() }
.getOrElse { "burrow-core unavailable" }
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(40, 40, 40, 40)
}
root.addView(TextView(this).apply {
text = getString(R.string.app_name)
textSize = 24f
})
root.addView(TextView(this).apply {
text = versionText
textSize = 14f
})
setContentView(root)
}
}

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Burrow</string>
</resources>

View file

@ -0,0 +1,8 @@
<resources>
<style name="BurrowTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:navigationBarColor">#ffffff</item>
<item name="android:statusBarColor">#ffffff</item>
</style>
</resources>

4
Android/build.gradle.kts Normal file
View file

@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.10.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.21" apply false
}

View file

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "BurrowAndroid"
include(":app")

View file

@ -18,6 +18,10 @@ INFOPLIST_KEY_LSUIElement[sdk=macosx*] = YES
INFOPLIST_KEY_NSMainNibFile[sdk=macosx*] = MainMenu INFOPLIST_KEY_NSMainNibFile[sdk=macosx*] = MainMenu
INFOPLIST_KEY_NSPrincipalClass[sdk=macosx*] = NSApplication INFOPLIST_KEY_NSPrincipalClass[sdk=macosx*] = NSApplication
INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*] = public.app-category.utilities INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*] = public.app-category.utilities
BURROW_SPARKLE_FEED_URL = https:/$()/releases.burrow.net/sparkle/appcast.xml
BURROW_SPARKLE_PUBLIC_ED_KEY = Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=
INFOPLIST_KEY_SUFeedURL[sdk=macosx*] = $(BURROW_SPARKLE_FEED_URL)
INFOPLIST_KEY_SUPublicEDKey[sdk=macosx*] = $(BURROW_SPARKLE_PUBLIC_ED_KEY)
CODE_SIGN_ENTITLEMENTS = App/App-iOS.entitlements CODE_SIGN_ENTITLEMENTS = App/App-iOS.entitlements
CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = App/App-macOS.entitlements CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = App/App-macOS.entitlements

View file

@ -1,12 +1,18 @@
#if os(macOS) #if os(macOS)
import AppKit import AppKit
import BurrowUI import BurrowUI
import Sparkle
import SwiftUI import SwiftUI
@main @main
@MainActor @MainActor
class AppDelegate: NSObject, NSApplicationDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
private var windowController: NSWindowController? private var windowController: NSWindowController?
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
private let quitItem: NSMenuItem = { private let quitItem: NSMenuItem = {
let quitItem = NSMenuItem( let quitItem = NSMenuItem(
@ -30,6 +36,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return item return item
}() }()
private lazy var checkForUpdatesItem: NSMenuItem = {
let item = NSMenuItem(
title: "Check for Updates...",
action: #selector(checkForUpdates(_:)),
keyEquivalent: ""
)
item.target = self
return item
}()
private let toggleItem: NSMenuItem = { private let toggleItem: NSMenuItem = {
let toggleView = NSHostingView(rootView: MenuItemToggleView()) let toggleView = NSHostingView(rootView: MenuItemToggleView())
toggleView.frame.size = CGSize(width: 300, height: 32) toggleView.frame.size = CGSize(width: 300, height: 32)
@ -45,6 +61,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.items = [ menu.items = [
toggleItem, toggleItem,
openItem, openItem,
checkForUpdatesItem,
.separator(), .separator(),
quitItem quitItem
] ]
@ -86,5 +103,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
windowController = controller windowController = controller
NSApplication.shared.activate(ignoringOtherApps: true) NSApplication.shared.activate(ignoringOtherApps: true)
} }
@objc
private func checkForUpdates(_ sender: Any?) {
updaterController.checkForUpdates(sender)
}
} }
#endif #endif

View file

@ -17,6 +17,7 @@
D03383B02C8E67E300F7C44E /* NIOTransportServices in Frameworks */ = {isa = PBXBuildFile; productRef = D044EE952C8DAB2800778185 /* NIOTransportServices */; }; D03383B02C8E67E300F7C44E /* NIOTransportServices in Frameworks */ = {isa = PBXBuildFile; productRef = D044EE952C8DAB2800778185 /* NIOTransportServices */; };
D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */; }; D05B9F7629E39EEC008CB1F9 /* BurrowApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D05B9F7529E39EEC008CB1F9 /* BurrowApp.swift */; };
D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; platformFilters = (macos, ); }; D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; platformFilters = (macos, ); };
D0A11C102F90000100112233 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = D0A11C0F2F90000100112233 /* DequeModule */; };
D0B1D1102C436152004B7823 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = D0B1D10F2C436152004B7823 /* AsyncAlgorithms */; }; D0B1D1102C436152004B7823 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = D0B1D10F2C436152004B7823 /* AsyncAlgorithms */; };
D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; };
D0BF09522C8E66F6000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; }; D0BF09522C8E66F6000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; };
@ -47,6 +48,7 @@
D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10042D10200100112233 /* burrow.grpc.swift */; }; D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10042D10200100112233 /* burrow.grpc.swift */; };
D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */ = {isa = PBXBuildFile; productRef = D0F7597D2C8DB30500126CF3 /* CGRPCZlib */; }; D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */ = {isa = PBXBuildFile; productRef = D0F7597D2C8DB30500126CF3 /* CGRPCZlib */; };
D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4992C8D921A007F820A /* Client.swift */; }; D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4992C8D921A007F820A /* Client.swift */; };
D0C0DE102FA0000100112233 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; platformFilters = (macos, ); productRef = D0C0DE122FA0000100112233 /* Sparkle */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -218,6 +220,7 @@
D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */, D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */,
D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */, D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */,
D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */, D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */,
D0C0DE102FA0000100112233 /* Sparkle in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -238,6 +241,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D0A11C102F90000100112233 /* DequeModule in Frameworks */,
D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */, D0D4E5702C8D9C62007F820A /* BurrowCore.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -460,6 +464,9 @@
D020F65C29E4A697002790F6 /* PBXTargetDependency */, D020F65C29E4A697002790F6 /* PBXTargetDependency */,
); );
name = App; name = App;
packageProductDependencies = (
D0C0DE122FA0000100112233 /* Sparkle */,
);
productName = Burrow; productName = Burrow;
productReference = D05B9F7229E39EEC008CB1F9 /* Burrow.app */; productReference = D05B9F7229E39EEC008CB1F9 /* Burrow.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@ -504,6 +511,7 @@
); );
name = UI; name = UI;
packageProductDependencies = ( packageProductDependencies = (
D0A11C0F2F90000100112233 /* DequeModule */,
); );
productName = Core; productName = Core;
productReference = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */; productReference = D0D4E5582C8D9BF2007F820A /* BurrowUI.framework */;
@ -567,6 +575,8 @@
D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */, D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */,
D044EE8F2C8DAB2000778185 /* XCRemoteSwiftPackageReference "swift-nio" */, D044EE8F2C8DAB2000778185 /* XCRemoteSwiftPackageReference "swift-nio" */,
D044EE942C8DAB2800778185 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */, D044EE942C8DAB2800778185 /* XCRemoteSwiftPackageReference "swift-nio-transport-services" */,
D0A11C0D2F90000100112233 /* XCRemoteSwiftPackageReference "swift-collections" */,
D0C0DE112FA0000100112233 /* XCRemoteSwiftPackageReference "Sparkle" */,
); );
productRefGroup = D05B9F7329E39EEC008CB1F9 /* Products */; productRefGroup = D05B9F7329E39EEC008CB1F9 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -949,6 +959,14 @@
minimumVersion = 1.21.0; minimumVersion = 1.21.0;
}; };
}; };
D0A11C0D2F90000100112233 /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.3;
};
};
D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; repositoryURL = "https://github.com/apple/swift-async-algorithms.git";
@ -957,6 +975,14 @@
minimumVersion = 1.0.1; minimumVersion = 1.0.1;
}; };
}; };
D0C0DE112FA0000100112233 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.9.2;
};
};
D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */ = { D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/grpc/grpc-swift.git"; repositoryURL = "https://github.com/grpc/grpc-swift.git";
@ -1001,11 +1027,21 @@
package = D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */; package = D0D4E4852C8D8F29007F820A /* XCRemoteSwiftPackageReference "swift-protobuf" */;
productName = SwiftProtobuf; productName = SwiftProtobuf;
}; };
D0A11C0F2F90000100112233 /* DequeModule */ = {
isa = XCSwiftPackageProductDependency;
package = D0A11C0D2F90000100112233 /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = DequeModule;
};
D0B1D10F2C436152004B7823 /* AsyncAlgorithms */ = { D0B1D10F2C436152004B7823 /* AsyncAlgorithms */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; package = D0B1D10E2C436152004B7823 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */;
productName = AsyncAlgorithms; productName = AsyncAlgorithms;
}; };
D0C0DE122FA0000100112233 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = D0C0DE112FA0000100112233 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
D0F7597D2C8DB30500126CF3 /* CGRPCZlib */ = { D0F7597D2C8DB30500126CF3 /* CGRPCZlib */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */; package = D0D4E4822C8D8EF6007F820A /* XCRemoteSwiftPackageReference "grpc-swift" */;

View file

@ -1,5 +1,5 @@
{ {
"originHash" : "fa512b990383b7e309c5854a5279817052294a8191a6d3c55c49cfb38e88c0c3", "originHash" : "5d81b59cbecacd7aae6aa598b32e3513c636250dadab9280a55b905bb0e0ee60",
"pins" : [ "pins" : [
{ {
"identity" : "grpc-swift", "identity" : "grpc-swift",
@ -10,6 +10,15 @@
"version" : "1.23.0" "version" : "1.23.0"
} }
}, },
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle.git",
"state" : {
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
}
},
{ {
"identity" : "swift-async-algorithms", "identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View file

@ -0,0 +1,41 @@
# Apple Certificates
Public Apple certificate material that can be inspected by release tooling lives
here. Private keys do not belong in this directory.
## Developer ID Application
- Apple certificate id: `9JKN6HXBHC`
- Certificate file: `Apple/Certificates/developer-id-application-9JKN6HXBHC.cer`
- Subject: `Developer ID Application: Conrad Kramer (87PW93R2ZR)`
- Issuer: `Developer ID Certification Authority`, `G2`
- Team id: `87PW93R2ZR`
- Validity: `2026-06-07T12:13:47Z` through `2031-06-08T12:13:46Z`
- SHA-256 fingerprint: `7B:2D:58:A5:BB:1E:34:5E:FB:FF:7F:E0:A1:CA:4F:B1:5B:6E:08:AF:CE:FD:BE:D0:2B:0A:E9:9C:82:E3:4F:74`
- Google KMS key:
`projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity/cryptoKeys/apple-developer-id-application/cryptoKeyVersions/1`
- KMS public-key SHA-256:
`9cfaf4755158f445f20a215ef52e7a7ba00da1a310e2e934ba7db5dbb29d2869`
The certificate was created from a CSR signed by the non-exportable Google KMS
key above. There is no `.p12` for this identity. Release signing must use a
signer that delegates the relevant Apple code-signing operations to Google KMS.
## iOS Distribution
- Apple certificate id: `3G42677598`
- Certificate file: `Apple/Certificates/ios-distribution-3G42677598.cer`
- Subject: `iPhone Distribution: Conrad Kramer (87PW93R2ZR)`
- Issuer: `Apple Worldwide Developer Relations Certification Authority`, `G3`
- Team id: `87PW93R2ZR`
- Validity: `2026-06-07T12:21:32Z` through `2027-06-07T12:21:31Z`
- SHA-256 fingerprint: `5A:2F:BA:6E:FB:CA:2D:58:01:1F:A0:C8:0F:CF:6B:58:BE:AA:73:7F:4E:F8:06:1C:8A:14:1B:22:8A:2A:29:2B`
- Google KMS key:
`projects/project-88c23ce9-918a-470a-b33/locations/global/keyRings/burrow-identity/cryptoKeys/apple-ios-distribution/cryptoKeyVersions/1`
- KMS public-key SHA-256:
`f8613ce059de90b29acf01cbd4a7deb37567653bd154bdf6c447098b5c4fae91`
The certificate was created from a CSR signed by the non-exportable Google KMS
key above through App Store Connect certificate creation. There is no `.p12`
for this identity. iOS signing requires a signing path that can delegate Apple
code-signing operations to Google KMS.

Binary file not shown.

View file

@ -4,5 +4,9 @@
<dict> <dict>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(INFOPLIST_KEY_CFBundleDisplayName)</string> <string>$(INFOPLIST_KEY_CFBundleDisplayName)</string>
<key>SUFeedURL</key>
<string>$(BURROW_SPARKLE_FEED_URL)</string>
<key>SUPublicEDKey</key>
<string>$(BURROW_SPARKLE_PUBLIC_ED_KEY)</string>
</dict> </dict>
</plist> </plist>

69
BUILD.bazel Normal file
View file

@ -0,0 +1,69 @@
filegroup(
name = "apple_project_files",
srcs = glob(
[
"Apple/**",
"Scripts/ci/install-apple-signing-assets.sh",
"Scripts/ci/package-apple-artifacts.sh",
"Scripts/version.sh",
],
exclude = [
"Apple/build/**",
"Apple/**/xcuserdata/**",
],
),
visibility = ["//visibility:public"],
)
filegroup(
name = "android_project_files",
srcs = glob(
[
"Android/**",
],
exclude = [
"Android/.gradle/**",
"Android/**/build/**",
],
),
visibility = ["//visibility:public"],
)
filegroup(
name = "mobile_rust_core_files",
srcs = glob(
[
"crates/burrow-core/**",
"crates/burrow-mobile-ffi/**",
],
) + [
"Cargo.lock",
"Cargo.toml",
"rust-toolchain.toml",
],
visibility = ["//visibility:public"],
)
alias(
name = "apple_release_ios",
actual = "//bazel/apple:release_ios_stamp",
visibility = ["//visibility:public"],
)
alias(
name = "apple_release_macos",
actual = "//bazel/apple:release_macos_stamp",
visibility = ["//visibility:public"],
)
alias(
name = "apple_release_all",
actual = "//bazel/apple:release_all_stamp",
visibility = ["//visibility:public"],
)
alias(
name = "android_check_stub",
actual = "//bazel/android:check_stub_stamp",
visibility = ["//visibility:public"],
)

142
Cargo.lock generated
View file

@ -767,6 +767,22 @@ dependencies = [
"x25519-dalek", "x25519-dalek",
] ]
[[package]]
name = "burrow-core"
version = "0.1.0"
[[package]]
name = "burrow-mobile-ffi"
version = "0.1.0"
dependencies = [
"burrow-core",
"jni",
]
[[package]]
name = "burrow-windows-stub"
version = "0.1.0"
[[package]] [[package]]
name = "by_address" name = "by_address"
version = "1.2.1" version = "1.2.1"
@ -845,6 +861,12 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cexpr" name = "cexpr"
version = "0.6.0" version = "0.6.0"
@ -1008,6 +1030,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.32" version = "0.4.32"
@ -2944,6 +2976,50 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.34" version = "0.1.34"
@ -7471,6 +7547,15 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"
@ -7516,6 +7601,21 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.48.5" version = "0.48.5"
@ -7573,6 +7673,12 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"
@ -7591,6 +7697,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.48.5" version = "0.48.5"
@ -7609,6 +7721,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.48.5" version = "0.48.5"
@ -7639,6 +7757,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.48.5" version = "0.48.5"
@ -7657,6 +7781,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.48.5" version = "0.48.5"
@ -7675,6 +7805,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.48.5" version = "0.48.5"
@ -7693,6 +7829,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.48.5" version = "0.48.5"

View file

@ -1,5 +1,11 @@
[workspace] [workspace]
members = ["burrow", "tun"] members = [
"burrow",
"tun",
"crates/burrow-core",
"crates/burrow-mobile-ffi",
"crates/burrow-windows-stub",
]
resolver = "2" resolver = "2"
exclude = ["burrow-gtk"] exclude = ["burrow-gtk"]

View file

@ -85,8 +85,8 @@ LABEL \
# https://github.com/opencontainers/image-spec/blob/master/annotations.md # https://github.com/opencontainers/image-spec/blob/master/annotations.md
org.opencontainers.image.title="burrow" \ org.opencontainers.image.title="burrow" \
org.opencontainers.image.description="Burrow is an open source tool for burrowing through firewalls, built by teenagers at Hack Club." \ org.opencontainers.image.description="Burrow is an open source tool for burrowing through firewalls, built by teenagers at Hack Club." \
org.opencontainers.image.url="https://github.com/hackclub/burrow" \ org.opencontainers.image.url="https://git.burrow.net/hackclub/burrow" \
org.opencontainers.image.source="https://github.com/hackclub/burrow" \ org.opencontainers.image.source="https://git.burrow.net/hackclub/burrow" \
org.opencontainers.image.vendor="hackclub" \ org.opencontainers.image.vendor="hackclub" \
org.opencontainers.image.licenses="GPL-3.0" org.opencontainers.image.licenses="GPL-3.0"

7
MODULE.bazel Normal file
View file

@ -0,0 +1,7 @@
module(
name = "burrow",
version = "0.1.0",
)
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "rules_shell", version = "0.6.1")

448
MODULE.bazel.lock generated Normal file
View file

@ -0,0 +1,448 @@
{
"lockFileVersion": 26,
"registryFileHashes": {
"https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497",
"https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2",
"https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589",
"https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915",
"https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed",
"https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16",
"https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1",
"https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215",
"https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2",
"https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c",
"https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896",
"https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85",
"https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e",
"https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1",
"https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4",
"https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d",
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
"https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101",
"https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8",
"https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d",
"https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d",
"https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a",
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
"https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
"https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080",
"https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a",
"https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5",
"https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d",
"https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651",
"https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138",
"https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
"https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f",
"https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd",
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
"https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4",
"https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6",
"https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f",
"https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108",
"https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46",
"https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
"https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0",
"https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000",
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
"https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74",
"https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
"https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f",
"https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee",
"https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37",
"https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615",
"https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814",
"https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d",
"https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc",
"https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580",
"https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96",
"https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7",
"https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c",
"https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df",
"https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92",
"https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95",
"https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0",
"https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d",
"https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42",
"https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79",
"https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e",
"https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34",
"https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680",
"https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206",
"https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a",
"https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4",
"https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa",
"https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
"https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
"https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
"https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68",
"https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715",
"https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
"https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
"https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191",
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
"https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
"https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c",
"https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37",
"https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84",
"https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07",
"https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
"https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8",
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
"https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31",
"https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a",
"https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6",
"https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab",
"https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe",
"https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017",
"https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939",
"https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2",
"https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d",
"https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6",
"https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7",
"https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909",
"https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
"https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd",
"https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
"https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0",
"https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d",
"https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c",
"https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a",
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
"https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
"https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73",
"https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96",
"https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e",
"https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f",
"https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300",
"https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382",
"https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed",
"https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58",
"https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937",
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
"https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6",
"https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8",
"https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8",
"https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b",
"https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c",
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
"https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400",
"https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66",
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
"https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c",
"https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5",
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca",
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806",
"https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198"
},
"selectedYankedVersions": {},
"moduleExtensions": {
"@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": {
"general": {
"bzlTransitiveDigest": "Ga4z8lQy1YQ5rAMy+dOl0dqcCEBnYNCXku8x3YQmDZI=",
"usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=",
"recordedInputs": [
"REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools"
],
"generatedRepoSpecs": {
"com_github_jetbrains_kotlin_git": {
"repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository",
"attributes": {
"urls": [
"https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip"
],
"sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88"
}
},
"com_github_jetbrains_kotlin": {
"repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository",
"attributes": {
"git_repository_name": "com_github_jetbrains_kotlin_git",
"compiler_version": "1.9.23"
}
},
"com_github_google_ksp": {
"repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository",
"attributes": {
"urls": [
"https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip"
],
"sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d",
"strip_version": "1.9.23-1.0.20"
}
},
"com_github_pinterest_ktlint": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file",
"attributes": {
"sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985",
"urls": [
"https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint"
],
"executable": true
}
},
"rules_android": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806",
"strip_prefix": "rules_android-0.1.1",
"urls": [
"https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip"
]
}
}
}
}
},
"@@rules_python+//python/extensions:config.bzl%config": {
"general": {
"bzlTransitiveDigest": "iibnRYgg8LpcfmH7EAnVwYePC3jsVaJ6Id8XxUjSZps=",
"usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=",
"recordedInputs": [
"REPO_MAPPING:rules_python+,bazel_tools bazel_tools",
"REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build",
"REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click",
"REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama",
"REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata",
"REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer",
"REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools",
"REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging",
"REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517",
"REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip",
"REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools",
"REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks",
"REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools",
"REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli",
"REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel",
"REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp"
],
"generatedRepoSpecs": {
"rules_python_internal": {
"repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo",
"attributes": {
"transition_setting_generators": {},
"transition_settings": []
}
},
"pypi__build": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl",
"sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__click": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl",
"sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__colorama": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl",
"sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__importlib_metadata": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl",
"sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__installer": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl",
"sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__more_itertools": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl",
"sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__packaging": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl",
"sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__pep517": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl",
"sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__pip": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl",
"sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__pip_tools": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl",
"sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__pyproject_hooks": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl",
"sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__setuptools": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl",
"sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__tomli": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl",
"sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__wheel": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl",
"sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
},
"pypi__zipp": {
"repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
"attributes": {
"url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl",
"sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e",
"type": "zip",
"build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n"
}
}
}
}
},
"@@rules_python+//python/uv:uv.bzl%uv": {
"general": {
"bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=",
"usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=",
"recordedInputs": [
"REPO_MAPPING:rules_python+,bazel_tools bazel_tools",
"REPO_MAPPING:rules_python+,platforms platforms"
],
"generatedRepoSpecs": {
"uv": {
"repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo",
"attributes": {
"toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'",
"toolchain_names": [
"none"
],
"toolchain_implementations": {
"none": "'@@rules_python+//python:none'"
},
"toolchain_compatible_with": {
"none": [
"@platforms//:incompatible"
]
},
"toolchain_target_settings": {}
}
}
}
}
}
},
"facts": {}
}

View file

@ -1,11 +1,10 @@
# Burrow # Burrow
![License](https://img.shields.io/github/license/hackclub/burrow) ![Apple Build Status](https://img.shields.io/github/actions/workflow/status/hackclub/burrow/build-apple.yml?branch=main&label=macos%2C%20ios&logo=Apple) ![Crate Build Status](https://img.shields.io/github/actions/workflow/status/hackclub/burrow/build-rust.yml?branch=main&label=crate&logo=Rust)
Burrow is an open source tool for burrowing through firewalls, built by teenagers at [Hack Club](https://hackclub.com/). Burrow is an open source tool for burrowing through firewalls, built by teenagers at [Hack Club](https://hackclub.com/).
`burrow` provides a simple command-line tool to open virtual interfaces and direct traffic through them. `burrow` provides a simple command-line tool to open virtual interfaces and direct traffic through them.
Routine verification now runs unprivileged with `cargo test --workspace --all-features`; only tunnel startup needs elevation. Routine verification now runs unprivileged with `cargo test --workspace --all-features`; only tunnel startup needs elevation.
The canonical source forge is [git.burrow.net/hackclub/burrow](https://git.burrow.net/hackclub/burrow).
The repository now carries its own design and deployment record: The repository now carries its own design and deployment record:
@ -18,7 +17,7 @@ The repository now carries its own design and deployment record:
## Contributing ## Contributing
Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh). Burrow is fully open source, you can fork the repo and start contributing easily. For more information and in-depth discussions, visit the `#burrow` channel on the [Hack Club Slack](https://hackclub.com/slack/), here you can ask for help and talk with other people interested in burrow. Checkout [GETTING_STARTED.md](./docs/GETTING_STARTED.md) for build instructions and [GTK_APP.md](./docs/GTK_APP.md) for the Linux app. Forge and deployment scaffolding live in [`flake.nix`](./flake.nix), [`nixos/`](./nixos), and [`.forgejo/workflows/`](./.forgejo/workflows/). Hosted mail backup operations live in [`docs/FORWARDEMAIL.md`](./docs/FORWARDEMAIL.md) and [`Tools/forwardemail-custom-s3.sh`](./Tools/forwardemail-custom-s3.sh). Platform service and distribution rollout notes live in [`docs/PLATFORM_SERVICES.md`](./docs/PLATFORM_SERVICES.md) and [`docs/DISTRIBUTION.md`](./docs/DISTRIBUTION.md).
Agent and governance-sensitive work should start with [AGENTS.md](./AGENTS.md), [CONSTITUTION.md](./CONSTITUTION.md), and the relevant BEPs under [`evolution/proposals/`](./evolution/proposals/). Identity and bootstrap metadata now live in [`contributors.nix`](./contributors.nix). Agent and governance-sensitive work should start with [AGENTS.md](./AGENTS.md), [CONSTITUTION.md](./CONSTITUTION.md), and the relevant BEPs under [`evolution/proposals/`](./evolution/proposals/). Identity and bootstrap metadata now live in [`contributors.nix`](./contributors.nix).
@ -49,6 +48,4 @@ Burrow is open source and licensed under the [GNU General Public License v3.0](.
## Contributors ## Contributors
<a href="https://github.com/hackclub/burrow/graphs/contributors"> See the canonical forge history for current contributors.
<img src="https://contrib.rocks/image?repo=hackclub/burrow" />
</a>

View file

@ -0,0 +1,164 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { webcrypto } from "node:crypto";
const crypto = globalThis.crypto ?? webcrypto;
function b64url(input) {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
return buf
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function parseArgs(argv) {
const out = { _: [] };
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") out.help = true;
else if (arg.startsWith("--")) {
const key = arg.slice(2);
const value = argv[i + 1];
if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`);
out[key] = value;
i++;
} else {
out._.push(arg);
}
}
return out;
}
async function makeJwt({ keyId, issuerId, keyPem }) {
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" };
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const pkcs8Der = Buffer.from(
keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""),
"base64",
);
const key = await crypto.subtle.importKey(
"pkcs8",
pkcs8Der,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
key,
new TextEncoder().encode(input),
);
return `${input}.${b64url(Buffer.from(sig))}`;
}
async function ascFetch(jwt, url, opts = {}) {
const res = await fetch(url, {
...opts,
headers: {
...(opts.headers ?? {}),
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
});
const text = await res.text();
let body;
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
if (!res.ok) {
throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`);
}
return body;
}
function certificateFileName(type, id) {
return `${type.toLowerCase().replace(/_/g, "-")}-${id}.cer`;
}
function csrContentForApi(csrFile) {
const bytes = fs.readFileSync(csrFile);
const text = bytes.toString("ascii");
const pemMatch = text.match(/-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/);
if (pemMatch) {
return pemMatch[1].replace(/\s+/g, "");
}
return bytes.toString("base64");
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log("Usage: create-asc-certificate.mjs --key-id ID --issuer-id ID --key-file AuthKey.p8 --certificate-type IOS_DISTRIBUTION --csr-file file.csr --out-dir DIR");
process.exit(0);
}
const keyId = args["key-id"] ?? process.env.APP_STORE_CONNECT_KEY_ID;
const issuerId = args["issuer-id"] ?? process.env.APP_STORE_CONNECT_ISSUER_ID;
const keyFile = args["key-file"] ?? process.env.APP_STORE_CONNECT_KEY_FILE;
const certificateType = args["certificate-type"] ?? "IOS_DISTRIBUTION";
const csrFile = args["csr-file"];
const outDir = args["out-dir"] ?? ".";
const outFile = args["out-file"];
const metadataFile = args["metadata-file"];
if (!keyId || !issuerId || !keyFile || !csrFile) {
throw new Error("required: --key-id --issuer-id --key-file --csr-file");
}
const jwt = await makeJwt({
keyId,
issuerId,
keyPem: fs.readFileSync(keyFile, "utf8"),
});
const csrContent = csrContentForApi(csrFile);
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/certificates", {
method: "POST",
body: JSON.stringify({
data: {
type: "certificates",
attributes: {
certificateType,
csrContent,
},
},
}),
});
const cert = created?.data;
const id = cert?.id;
const content = cert?.attributes?.certificateContent;
if (!id || !content) {
throw new Error(`ASC response did not include certificate id/content: ${JSON.stringify(created).slice(0, 2000)}`);
}
fs.mkdirSync(outDir, { recursive: true });
const certificatePath = outFile ?? path.join(outDir, certificateFileName(certificateType, id));
fs.writeFileSync(certificatePath, Buffer.from(content, "base64"));
const metadata = {
id,
certificateType: cert?.attributes?.certificateType ?? certificateType,
displayName: cert?.attributes?.displayName,
name: cert?.attributes?.name,
platform: cert?.attributes?.platform,
expirationDate: cert?.attributes?.expirationDate,
serialNumber: cert?.attributes?.serialNumber,
certificatePath,
};
if (metadataFile) {
fs.writeFileSync(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`);
}
process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`);
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

249
Scripts/apple/google-kms-csr.py Executable file
View file

@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""Build an Apple-compatible PKCS#10 CSR backed by Google Cloud KMS.
The script intentionally implements only the RSA/SHA-256 subset Burrow needs for
Apple Developer ID and iOS Distribution certificate requests. It fetches the KMS
public key, builds CertificationRequestInfo locally, asks KMS to sign that DER
payload, and writes a standard PEM CSR.
"""
from __future__ import annotations
import argparse
import base64
import os
import subprocess
import sys
import tempfile
from pathlib import Path
DEFAULT_PROJECT = "project-88c23ce9-918a-470a-b33"
DEFAULT_LOCATION = "global"
DEFAULT_KEYRING = "burrow-identity"
DEFAULT_KEY = "apple-developer-id-application"
DEFAULT_VERSION = "1"
DEFAULT_COMMON_NAME = "Burrow Developer ID Application"
DEFAULT_EMAIL = "release@burrow.net"
def der_length(length: int) -> bytes:
if length < 0:
raise ValueError("negative DER length")
if length < 0x80:
return bytes([length])
encoded = length.to_bytes((length.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(encoded)]) + encoded
def der(tag: int, body: bytes) -> bytes:
return bytes([tag]) + der_length(len(body)) + body
def der_sequence(*items: bytes) -> bytes:
return der(0x30, b"".join(items))
def der_set(*items: bytes) -> bytes:
return der(0x31, b"".join(items))
def der_integer(value: int) -> bytes:
if value < 0:
raise ValueError("negative INTEGER is not supported")
raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big")
if raw[0] & 0x80:
raw = b"\x00" + raw
return der(0x02, raw)
def der_null() -> bytes:
return der(0x05, b"")
def der_bit_string(value: bytes) -> bytes:
return der(0x03, b"\x00" + value)
def der_utf8(value: str) -> bytes:
return der(0x0C, value.encode("utf-8"))
def der_printable(value: str) -> bytes:
return der(0x13, value.encode("ascii"))
def der_ia5(value: str) -> bytes:
return der(0x16, value.encode("ascii"))
def der_oid(dotted: str) -> bytes:
parts = [int(part) for part in dotted.split(".")]
if len(parts) < 2 or parts[0] > 2 or parts[1] > 39:
raise ValueError(f"unsupported OID: {dotted}")
body = bytes([40 * parts[0] + parts[1]])
for value in parts[2:]:
if value < 0:
raise ValueError(f"negative OID component: {dotted}")
encoded = [value & 0x7F]
value >>= 7
while value:
encoded.append(0x80 | (value & 0x7F))
value >>= 7
body += bytes(reversed(encoded))
return der(0x06, body)
def name_attribute(oid: str, value_der: bytes) -> bytes:
return der_set(der_sequence(der_oid(oid), value_der))
def subject_name(common_name: str, email_address: str, country: str | None) -> bytes:
attrs: list[bytes] = []
if country:
if len(country) != 2 or not country.isalpha():
raise ValueError("--country must be a two-letter ISO country code")
attrs.append(name_attribute("2.5.4.6", der_printable(country.upper())))
if email_address:
attrs.append(name_attribute("1.2.840.113549.1.9.1", der_ia5(email_address)))
attrs.append(name_attribute("2.5.4.3", der_utf8(common_name)))
return der_sequence(*attrs)
def pem_to_der(pem: bytes) -> bytes:
lines = [
line.strip()
for line in pem.splitlines()
if line and not line.startswith(b"-----")
]
if not lines:
raise ValueError("PEM input did not contain base64 data")
return base64.b64decode(b"".join(lines), validate=True)
def pem_wrap(label: str, body: bytes) -> str:
encoded = base64.b64encode(body).decode("ascii")
lines = [f"-----BEGIN {label}-----"]
lines.extend(encoded[i : i + 64] for i in range(0, len(encoded), 64))
lines.append(f"-----END {label}-----")
return "\n".join(lines) + "\n"
def run(command: list[str]) -> None:
subprocess.run(command, check=True)
def fetch_public_key(args: argparse.Namespace, output: Path) -> None:
command = [
args.gcloud,
"kms",
"keys",
"versions",
"get-public-key",
args.kms_version,
"--location",
args.kms_location,
"--keyring",
args.kms_keyring,
"--key",
args.kms_key,
"--output-file",
str(output),
]
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
run(command)
def kms_sign(args: argparse.Namespace, payload: bytes) -> bytes:
with tempfile.TemporaryDirectory(prefix="burrow-kms-csr.") as tmp:
payload_path = Path(tmp) / "certification-request-info.der"
signature_path = Path(tmp) / "signature.b64"
payload_path.write_bytes(payload)
command = [
args.gcloud,
"kms",
"asymmetric-sign",
"--location",
args.kms_location,
"--keyring",
args.kms_keyring,
"--key",
args.kms_key,
"--version",
args.kms_version,
"--digest-algorithm",
"sha256",
"--input-file",
str(payload_path),
"--signature-file",
str(signature_path),
]
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
run(command)
signature = signature_path.read_bytes()
try:
return base64.b64decode(signature.strip(), validate=True)
except ValueError:
return signature
def build_csr(args: argparse.Namespace, spki_der: bytes) -> bytes:
cri = der_sequence(
der_integer(0),
subject_name(args.common_name, args.email_address, args.country),
spki_der,
b"\xA0\x00",
)
signature = kms_sign(args, cri)
signature_algorithm = der_sequence(
der_oid("1.2.840.113549.1.1.11"),
der_null(),
)
return der_sequence(cri, signature_algorithm, der_bit_string(signature))
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate an Apple-compatible CSR backed by a Google Cloud KMS RSA signing key.",
)
parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("BURROW_GOOGLE_PROJECT_ID") or DEFAULT_PROJECT)
parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", DEFAULT_LOCATION))
parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", DEFAULT_KEYRING))
parser.add_argument("--kms-key", default=os.environ.get("BURROW_APPLE_KMS_KEY") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_KMS_KEY", DEFAULT_KEY))
parser.add_argument("--kms-version", default=os.environ.get("BURROW_KMS_VERSION", DEFAULT_VERSION))
parser.add_argument("--common-name", default=os.environ.get("BURROW_APPLE_CSR_COMMON_NAME") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_COMMON_NAME", DEFAULT_COMMON_NAME))
parser.add_argument("--email-address", default=os.environ.get("BURROW_APPLE_CSR_EMAIL") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_EMAIL", DEFAULT_EMAIL))
parser.add_argument("--country", default=os.environ.get("BURROW_APPLE_CSR_COUNTRY") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_COUNTRY", "US"))
parser.add_argument("--public-key-pem", help="Existing KMS public-key PEM path. If omitted, gcloud fetches it.")
parser.add_argument("--public-key-output", help="Write the fetched/used public key PEM to this path.")
parser.add_argument("--output", default="developer-id-application.certSigningRequest")
parser.add_argument("--gcloud", default=os.environ.get("GCLOUD", "gcloud"))
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
output = Path(args.output)
with tempfile.TemporaryDirectory(prefix="burrow-kms-csr.") as tmp:
public_key_path = Path(args.public_key_pem) if args.public_key_pem else Path(tmp) / "kms-public.pem"
if not args.public_key_pem:
fetch_public_key(args, public_key_path)
public_key_pem = public_key_path.read_bytes()
spki_der = pem_to_der(public_key_pem)
csr = build_csr(args, spki_der)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(pem_wrap("CERTIFICATE REQUEST", csr), encoding="ascii")
if args.public_key_output:
public_output = Path(args.public_key_output)
public_output.parent.mkdir(parents=True, exist_ok=True)
public_output.write_bytes(public_key_pem)
print(f"wrote {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,327 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { webcrypto } from "node:crypto";
const crypto = globalThis.crypto ?? webcrypto;
function b64url(input) {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
return buf
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function parseArgs(argv) {
const out = { _: [] };
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") out.help = true;
else if (arg.startsWith("--")) {
const key = arg.slice(2);
const value = argv[i + 1];
if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`);
out[key] = value;
i++;
} else {
out._.push(arg);
}
}
return out;
}
async function makeJwt({ keyId, issuerId, keyPem }) {
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" };
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const pkcs8Der = Buffer.from(
keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""),
"base64",
);
const key = await crypto.subtle.importKey(
"pkcs8",
pkcs8Der,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
key,
new TextEncoder().encode(input),
);
return `${input}.${b64url(Buffer.from(sig))}`;
}
async function ascFetch(jwt, url, opts = {}) {
const res = await fetch(url, {
...opts,
headers: {
...(opts.headers ?? {}),
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
});
const text = await res.text();
let body;
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
if (!res.ok) {
throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`);
}
return body;
}
function truthy(value) {
return ["1", "true", "yes", "on"].includes(String(value ?? "").toLowerCase());
}
function safeFileName(identifier, suffix) {
return `${identifier.replace(/[^A-Za-z0-9]/g, "").toLowerCase()}${suffix}`;
}
function bundleDisplayName(identifier, appId) {
return identifier === appId ? "Burrow App" : "Burrow Network Extension";
}
async function findCertificate(jwt, preferredTypes, preferredId) {
const certs = await ascFetch(
jwt,
"https://api.appstoreconnect.apple.com/v1/certificates?limit=200",
);
const data = certs.data ?? [];
if (preferredId) {
const cert = data.find((item) => item?.id === preferredId);
if (!cert) throw new Error(`unable to locate certificate id ${preferredId} via ASC API`);
const type = cert?.attributes?.certificateType;
if (preferredTypes.length && !preferredTypes.includes(type)) {
throw new Error(`certificate ${preferredId} has type ${type}, expected ${preferredTypes.join(" or ")}`);
}
return cert;
}
for (const type of preferredTypes) {
const cert = data.find((item) => item?.attributes?.certificateType === type);
if (cert) return cert;
}
throw new Error(`unable to locate certificate type ${preferredTypes.join(" or ")} via ASC API`);
}
async function resolveBundle(jwt, target, { createMissing, platform }) {
const bundleResp = await ascFetch(
jwt,
`https://api.appstoreconnect.apple.com/v1/bundleIds?limit=5&filter[identifier]=${encodeURIComponent(target.identifier)}`,
);
let bundle = (bundleResp.data ?? []).find(
(item) => item?.attributes?.identifier === target.identifier,
);
if (bundle || !createMissing) return bundle;
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIds", {
method: "POST",
body: JSON.stringify({
data: {
type: "bundleIds",
attributes: {
identifier: target.identifier,
name: target.bundleName ?? target.name,
platform,
},
},
}),
});
bundle = created?.data;
if (bundle?.id) {
process.stdout.write(`created bundle id ${target.identifier} (${platform})\n`);
}
return bundle;
}
async function listCapabilityTypes(jwt, bundleId) {
const resp = await ascFetch(
jwt,
`https://api.appstoreconnect.apple.com/v1/bundleIds/${bundleId}/bundleIdCapabilities`,
);
return new Set((resp.data ?? []).map((item) => item?.attributes?.capabilityType).filter(Boolean));
}
async function enableCapability(jwt, bundleId, capabilityType, settings) {
await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIdCapabilities", {
method: "POST",
body: JSON.stringify({
data: {
type: "bundleIdCapabilities",
attributes: {
capabilityType,
...(settings ? { settings } : {}),
},
relationships: {
bundleId: {
data: {
id: bundleId,
type: "bundleIds",
},
},
},
},
}),
});
}
async function profileIncludesCertificate(jwt, profileId, certificateId) {
const resp = await ascFetch(
jwt,
`https://api.appstoreconnect.apple.com/v1/profiles/${profileId}/certificates?limit=200`,
);
return (resp.data ?? []).some((item) => item?.id === certificateId);
}
async function ensureCapabilities(jwt, bundle, capabilities) {
const existing = await listCapabilityTypes(jwt, bundle.id);
for (const capability of capabilities) {
if (existing.has(capability.type)) continue;
try {
await enableCapability(jwt, bundle.id, capability.type, capability.settings);
process.stdout.write(`enabled ${capability.type} for ${bundle.attributes.identifier}\n`);
} catch (err) {
process.stderr.write(`warning: could not enable ${capability.type} for ${bundle.attributes.identifier}: ${err.message}\n`);
}
}
}
async function syncTarget(jwt, cert, profileType, target, outDir, options) {
const bundle = await resolveBundle(jwt, target, options);
if (!bundle) throw new Error(`bundleId not found in ASC: ${target.identifier}`);
if (options.enableCapabilities && target.capabilities?.length) {
await ensureCapabilities(jwt, bundle, target.capabilities);
}
const existing = await ascFetch(
jwt,
`https://api.appstoreconnect.apple.com/v1/profiles?limit=10&filter[profileType]=${encodeURIComponent(profileType)}&filter[name]=${encodeURIComponent(target.name)}`,
);
let profileId = (existing.data ?? []).find((item) => item?.attributes?.name === target.name)?.id;
if (profileId && options.replaceCertificateMismatch) {
const includesCertificate = await profileIncludesCertificate(jwt, profileId, cert.id);
if (!includesCertificate) {
await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`, {
method: "DELETE",
});
process.stdout.write(`deleted stale profile ${target.name}; it did not include certificate ${cert.id}\n`);
profileId = undefined;
}
}
if (!profileId) {
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/profiles", {
method: "POST",
body: JSON.stringify({
data: {
type: "profiles",
attributes: { name: target.name, profileType },
relationships: {
bundleId: { data: { type: "bundleIds", id: bundle.id } },
certificates: { data: [{ type: "certificates", id: cert.id }] },
},
},
}),
});
profileId = created?.data?.id;
}
if (!profileId) throw new Error(`failed to resolve/create profile for ${target.identifier}`);
const profile = await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`);
const content = profile?.data?.attributes?.profileContent;
if (!content) throw new Error(`profileContent missing for ${target.name} (${profileId})`);
fs.mkdirSync(outDir, { recursive: true });
const profileBytes = Buffer.from(content, "base64");
const profilePath = path.join(outDir, target.file);
fs.writeFileSync(profilePath, profileBytes);
if (target.mobileFile) {
fs.writeFileSync(path.join(outDir, target.mobileFile), profileBytes);
}
process.stdout.write(`synced ${target.identifier} -> ${profilePath}\n`);
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log("Usage: sync-provisioning-profiles.mjs --platform ios|macos|all --key-id ID --issuer-id ID --key-file AuthKey.p8 --out-dir DIR [--ios-certificate-id ID] [--macos-certificate-id ID] [--replace-certificate-mismatch true]");
process.exit(0);
}
const platform = args.platform ?? "all";
const keyId = args["key-id"];
const issuerId = args["issuer-id"];
const keyFile = args["key-file"];
const outDir = args["out-dir"] ?? ".forgejo/actions/export";
const appId = args["app-id"] ?? "com.hackclub.burrow";
const networkId = args["network-id"] ?? `${appId}.network`;
const iosCertificateId = args["ios-certificate-id"] ?? process.env.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID;
const macosCertificateId = args["macos-certificate-id"] ?? process.env.BURROW_DEVELOPER_ID_CERTIFICATE_ID;
const createMissing = truthy(args["create-missing-bundle-ids"]);
const enableCapabilities = createMissing || truthy(args["enable-capabilities"]);
const replaceCertificateMismatch = truthy(args["replace-certificate-mismatch"]);
const bundlePlatform = args["bundle-platform"] ?? "UNIVERSAL";
const associatedDomains = (args["associated-domains"] ?? "applinks:burrow.rs?mode=developer,webcredentials:burrow.rs?mode=developer")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const appCapabilities = [
{ type: "NETWORK_EXTENSIONS" },
{ type: "APP_GROUPS" },
{
type: "ASSOCIATED_DOMAINS",
settings: [{ key: "ASSOCIATED_DOMAIN_IDS", options: associatedDomains.map((key) => ({ key, enabled: true })) }],
},
];
const extensionCapabilities = [
{ type: "NETWORK_EXTENSIONS" },
{ type: "APP_GROUPS" },
];
const options = { createMissing, enableCapabilities, replaceCertificateMismatch, platform: bundlePlatform };
if (!keyId || !issuerId || !keyFile) throw new Error("required: --key-id --issuer-id --key-file");
const jwt = await makeJwt({ keyId, issuerId, keyPem: fs.readFileSync(keyFile, "utf8") });
if (platform === "ios" || platform === "all") {
const cert = await findCertificate(jwt, ["IOS_DISTRIBUTION", "DISTRIBUTION", "APPLE_DISTRIBUTION"], iosCertificateId);
const targets = [appId, networkId].map((identifier) => ({
identifier,
name: `${identifier}.app-store`,
bundleName: bundleDisplayName(identifier, appId),
file: safeFileName(identifier, "appstore.provisionprofile"),
mobileFile: safeFileName(identifier, "appstore.mobileprovision"),
capabilities: identifier === appId ? appCapabilities : extensionCapabilities,
}));
for (const target of targets) {
await syncTarget(jwt, cert, "IOS_APP_STORE", target, outDir, options);
}
}
if (platform === "macos" || platform === "all") {
const cert = await findCertificate(jwt, ["DEVELOPER_ID_APPLICATION"], macosCertificateId);
const targets = [appId, networkId].map((identifier) => ({
identifier,
name: `${identifier}.developer-id`,
bundleName: bundleDisplayName(identifier, appId),
file: safeFileName(identifier, "developerid.provisionprofile"),
capabilities: identifier === appId ? appCapabilities : extensionCapabilities,
}));
for (const target of targets) {
await syncTarget(jwt, cert, "MAC_APP_DIRECT", target, outDir, options);
}
}
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

View file

@ -6,6 +6,7 @@ bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
directory_json="${AUTHENTIK_BURROW_DIRECTORY_JSON:-[]}" directory_json="${AUTHENTIK_BURROW_DIRECTORY_JSON:-[]}"
users_group="${AUTHENTIK_BURROW_USERS_GROUP:-burrow-users}" users_group="${AUTHENTIK_BURROW_USERS_GROUP:-burrow-users}"
admins_group="${AUTHENTIK_BURROW_ADMINS_GROUP:-burrow-admins}" admins_group="${AUTHENTIK_BURROW_ADMINS_GROUP:-burrow-admins}"
extra_groups_json="${AUTHENTIK_BURROW_EXTRA_GROUPS_JSON:-[]}"
forgejo_application_slug="${AUTHENTIK_FORGEJO_APPLICATION_SLUG:-}" forgejo_application_slug="${AUTHENTIK_FORGEJO_APPLICATION_SLUG:-}"
usage() { usage() {
@ -20,6 +21,7 @@ Optional environment:
AUTHENTIK_URL AUTHENTIK_URL
AUTHENTIK_BURROW_USERS_GROUP AUTHENTIK_BURROW_USERS_GROUP
AUTHENTIK_BURROW_ADMINS_GROUP AUTHENTIK_BURROW_ADMINS_GROUP
AUTHENTIK_BURROW_EXTRA_GROUPS_JSON
AUTHENTIK_FORGEJO_APPLICATION_SLUG AUTHENTIK_FORGEJO_APPLICATION_SLUG
EOF EOF
} }
@ -39,6 +41,11 @@ if ! printf '%s' "$directory_json" | jq -e 'type == "array"' >/dev/null; then
exit 1 exit 1
fi fi
if ! printf '%s' "$extra_groups_json" | jq -e 'type == "array"' >/dev/null; then
echo "error: AUTHENTIK_BURROW_EXTRA_GROUPS_JSON must be a JSON array" >&2
exit 1
fi
api() { api() {
local method="$1" local method="$1"
local path="$2" local path="$2"
@ -251,6 +258,9 @@ ensure_application_group_binding() {
wait_for_authentik wait_for_authentik
ensure_group "$users_group" >/dev/null ensure_group "$users_group" >/dev/null
ensure_group "$admins_group" >/dev/null ensure_group "$admins_group" >/dev/null
while IFS= read -r group_name; do
ensure_group "$group_name" >/dev/null
done < <(printf '%s\n' "$extra_groups_json" | jq -r '.[]')
while IFS= read -r user_spec; do while IFS= read -r user_spec; do
ensure_user "$user_spec" ensure_user "$user_spec"

View file

@ -11,8 +11,7 @@ client_secret="${AUTHENTIK_FORGEJO_CLIENT_SECRET:-}"
launch_url="${AUTHENTIK_FORGEJO_LAUNCH_URL:-https://git.burrow.net/}" launch_url="${AUTHENTIK_FORGEJO_LAUNCH_URL:-https://git.burrow.net/}"
redirect_uris_json="${AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON:-[ redirect_uris_json="${AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON:-[
\"https://git.burrow.net/user/oauth2/burrow.net/callback\", \"https://git.burrow.net/user/oauth2/burrow.net/callback\",
\"https://git.burrow.net/user/oauth2/authentik/callback\", \"https://git.burrow.net/user/oauth2/authentik/callback\"
\"https://git.burrow.net/user/oauth2/GitHub/callback\"
]}" ]}"
usage() { usage() {

View file

@ -0,0 +1,285 @@
#!/usr/bin/env bash
set -euo pipefail
authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}"
bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
application_slug="${AUTHENTIK_GOOGLE_WIF_APPLICATION_SLUG:-google-cloud}"
application_name="${AUTHENTIK_GOOGLE_WIF_APPLICATION_NAME:-Google Cloud WIF}"
provider_name="${AUTHENTIK_GOOGLE_WIF_PROVIDER_NAME:-Google Cloud WIF}"
template_slug="${AUTHENTIK_GOOGLE_WIF_TEMPLATE_SLUG:-ts}"
client_id="${AUTHENTIK_GOOGLE_WIF_CLIENT_ID:-google-wif.burrow.net}"
client_secret="${AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET:-}"
launch_url="${AUTHENTIK_GOOGLE_WIF_LAUNCH_URL:-https://console.cloud.google.com/}"
redirect_uris_json="${AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON:-[]}"
access_group="${AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP:-burrow-automation}"
usage() {
cat <<'EOF'
Usage: Scripts/authentik-sync-google-wif-oidc.sh
Reconciles the Authentik OIDC provider used as the Google Workload Identity
Federation issuer for Burrow release runners.
Required environment:
AUTHENTIK_BOOTSTRAP_TOKEN
AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET
Optional environment:
AUTHENTIK_URL
AUTHENTIK_GOOGLE_WIF_APPLICATION_SLUG
AUTHENTIK_GOOGLE_WIF_APPLICATION_NAME
AUTHENTIK_GOOGLE_WIF_PROVIDER_NAME
AUTHENTIK_GOOGLE_WIF_TEMPLATE_SLUG
AUTHENTIK_GOOGLE_WIF_CLIENT_ID
AUTHENTIK_GOOGLE_WIF_LAUNCH_URL
AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON
AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ -z "$bootstrap_token" ]]; then
echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2
exit 1
fi
if [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then
echo "Google WIF OIDC client secret is not configured; skipping Authentik Google WIF sync." >&2
exit 0
fi
if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array"' >/dev/null; then
echo "error: AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON must be a JSON array" >&2
exit 1
fi
api() {
local method="$1"
local path="$2"
local data="${3:-}"
if [[ -n "$data" ]]; then
curl -fsS \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
-H "Content-Type: application/json" \
-d "$data" \
"${authentik_url}${path}"
else
curl -fsS \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
"${authentik_url}${path}"
fi
}
api_with_status() {
local method="$1"
local path="$2"
local data="${3:-}"
local response_file status
response_file="$(mktemp)"
trap 'rm -f "$response_file"' RETURN
if [[ -n "$data" ]]; then
status="$(
curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
-H "Content-Type: application/json" \
-d "$data" \
"${authentik_url}${path}"
)"
else
status="$(
curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
"${authentik_url}${path}"
)"
fi
printf '%s\n' "$status"
cat "$response_file"
}
wait_for_authentik() {
for _ in $(seq 1 90); do
if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
echo "error: Authentik did not become ready at ${authentik_url}" >&2
exit 1
}
wait_for_authentik
template_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c --arg slug "$template_slug" '.results[]? | select(.assigned_application_slug == $slug)' \
| head -n1
)"
if [[ -z "$template_provider" ]]; then
echo "error: could not resolve Authentik OAuth provider template ${template_slug}" >&2
exit 1
fi
authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')"
invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')"
property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')"
signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')"
provider_payload="$(
jq -n \
--arg name "$provider_name" \
--arg authorization_flow "$authorization_flow" \
--arg invalidation_flow "$invalidation_flow" \
--arg client_id "$client_id" \
--arg client_secret "$client_secret" \
--arg signing_key "$signing_key" \
--argjson property_mappings "$property_mappings" \
--argjson redirect_uris "$redirect_uris_json" \
'{
name: $name,
authorization_flow: $authorization_flow,
invalidation_flow: $invalidation_flow,
client_type: "confidential",
grant_types: ["client_credentials"],
client_id: $client_id,
client_secret: $client_secret,
include_claims_in_id_token: true,
redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})),
property_mappings: $property_mappings,
signing_key: $signing_key,
issuer_mode: "per_provider",
sub_mode: "hashed_user_id"
}'
)"
existing_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c \
--arg application_slug "$application_slug" \
--arg provider_name "$provider_name" \
'.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \
| head -n1
)"
if [[ -n "$existing_provider" ]]; then
provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')"
api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null
else
provider_pk="$(
api POST "/api/v3/providers/oauth2/" "$provider_payload" \
| jq -r '.pk // empty'
)"
fi
if [[ -z "${provider_pk:-}" ]]; then
echo "error: Google WIF OIDC provider did not return a primary key" >&2
exit 1
fi
application_payload="$(
jq -n \
--arg name "$application_name" \
--arg slug "$application_slug" \
--arg provider "$provider_pk" \
--arg launch_url "$launch_url" \
'{
name: $name,
slug: $slug,
provider: ($provider | tonumber),
meta_launch_url: $launch_url,
open_in_new_tab: false,
policy_engine_mode: "any"
}'
)"
existing_application="$(
api GET "/api/v3/core/applications/?page_size=200" \
| jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \
| head -n1
)"
if [[ -n "$existing_application" ]]; then
application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')"
api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null
else
create_application_result="$(
api_with_status POST "/api/v3/core/applications/" "$application_payload"
)"
create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')"
create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')"
if [[ "$create_application_status" =~ ^20[01]$ ]]; then
application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')"
elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e '
(.slug // [] | index("Application with this slug already exists.")) != null
or (.provider // [] | index("Application with this provider already exists.")) != null
' >/dev/null; then
application_pk="$(
api GET "/api/v3/core/applications/?page_size=200" \
| jq -r --arg slug "$application_slug" '.results[]? | select(.slug == $slug) | .pk // empty' \
| head -n1
)"
else
printf '%s\n' "$create_application_body" >&2
echo "error: could not reconcile Authentik application ${application_slug}" >&2
exit 1
fi
fi
if [[ -z "${application_pk:-}" ]]; then
echo "error: Google WIF OIDC application did not return a primary key" >&2
exit 1
fi
if [[ -n "$access_group" ]]; then
group_pk="$(
api GET "/api/v3/core/groups/?page_size=200" \
| jq -r --arg name "$access_group" '.results[]? | select(.name == $name) | .pk' \
| head -n1
)"
if [[ -z "$group_pk" ]]; then
echo "warning: Authentik Google WIF access group ${access_group} was not found; application policy left unchanged." >&2
else
api POST "/api/v3/policies/bindings/" "$(
jq -n \
--arg target "$application_pk" \
--arg group "$group_pk" \
'{
target: $target,
group: $group,
negate: false,
order: 0,
enabled: true,
timeout: 30,
failure_result: false
}'
)" >/dev/null || true
fi
fi
for _ in $(seq 1 30); do
if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then
echo "Synced Authentik Google WIF OIDC application ${application_slug} (${application_name})."
exit 0
fi
sleep 2
done
echo "warning: Google WIF OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2
echo "Synced Authentik Google WIF OIDC application ${application_slug} (${application_name})."

View file

@ -0,0 +1,332 @@
#!/usr/bin/env bash
set -euo pipefail
authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}"
bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}"
application_slug="${AUTHENTIK_GRAFANA_APPLICATION_SLUG:-grafana}"
application_name="${AUTHENTIK_GRAFANA_APPLICATION_NAME:-Burrow Grafana}"
provider_name="${AUTHENTIK_GRAFANA_PROVIDER_NAME:-Burrow Grafana}"
template_slug="${AUTHENTIK_GRAFANA_TEMPLATE_SLUG:-ts}"
client_id="${AUTHENTIK_GRAFANA_CLIENT_ID:-graphs.burrow.net}"
client_secret="${AUTHENTIK_GRAFANA_CLIENT_SECRET:-}"
launch_url="${AUTHENTIK_GRAFANA_LAUNCH_URL:-https://graphs.burrow.net/}"
access_group="${AUTHENTIK_GRAFANA_ACCESS_GROUP:-}"
redirect_uris_json="${AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON:-[
\"https://graphs.burrow.net/login/generic_oauth\"
]}"
usage() {
cat <<'EOF'
Usage: Scripts/authentik-sync-grafana-oidc.sh
Required environment:
AUTHENTIK_BOOTSTRAP_TOKEN
AUTHENTIK_GRAFANA_CLIENT_SECRET
Optional environment:
AUTHENTIK_URL
AUTHENTIK_GRAFANA_APPLICATION_SLUG
AUTHENTIK_GRAFANA_APPLICATION_NAME
AUTHENTIK_GRAFANA_PROVIDER_NAME
AUTHENTIK_GRAFANA_TEMPLATE_SLUG
AUTHENTIK_GRAFANA_CLIENT_ID
AUTHENTIK_GRAFANA_LAUNCH_URL
AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON
AUTHENTIK_GRAFANA_ACCESS_GROUP
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ -z "$bootstrap_token" ]]; then
echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2
exit 1
fi
if [[ -z "$client_secret" || "$client_secret" == PENDING* ]]; then
echo "Grafana OIDC client secret is not configured; skipping Authentik Grafana sync." >&2
exit 0
fi
if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then
echo "error: AUTHENTIK_GRAFANA_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2
exit 1
fi
api() {
local method="$1"
local path="$2"
local data="${3:-}"
if [[ -n "$data" ]]; then
curl -fsS \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
-H "Content-Type: application/json" \
-d "$data" \
"${authentik_url}${path}"
else
curl -fsS \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
"${authentik_url}${path}"
fi
}
api_with_status() {
local method="$1"
local path="$2"
local data="${3:-}"
local response_file status
response_file="$(mktemp)"
trap 'rm -f "$response_file"' RETURN
if [[ -n "$data" ]]; then
status="$(
curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
-H "Content-Type: application/json" \
-d "$data" \
"${authentik_url}${path}"
)"
else
status="$(
curl -sS \
-o "$response_file" \
-w '%{http_code}' \
-X "$method" \
-H "Authorization: Bearer ${bootstrap_token}" \
"${authentik_url}${path}"
)"
fi
printf '%s\n' "$status"
cat "$response_file"
}
wait_for_authentik() {
for _ in $(seq 1 90); do
if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
echo "error: Authentik did not become ready at ${authentik_url}" >&2
exit 1
}
lookup_group_pk() {
local group_name="$1"
api GET "/api/v3/core/groups/?page_size=200" \
| jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \
| head -n1
}
lookup_application_pk() {
local slug="$1"
local application_pk lookup_result lookup_status
application_pk="$(
api GET "/api/v3/core/applications/?page_size=200" \
| jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \
| head -n1
)"
if [[ -n "$application_pk" ]]; then
printf '%s\n' "$application_pk"
return 0
fi
lookup_result="$(api_with_status GET "/api/v3/core/applications/${slug}/")"
lookup_status="$(printf '%s\n' "$lookup_result" | sed -n '1p')"
if [[ "$lookup_status" =~ ^20[01]$ ]]; then
printf '%s\n' "$lookup_result" | sed '1d' | jq -r '.pk // empty'
fi
}
ensure_application_group_binding() {
local application_slug="$1"
local group_name="$2"
local application_pk group_pk existing payload binding_pk
application_pk="$(lookup_application_pk "$application_slug")"
if [[ -z "$application_pk" ]]; then
echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2
return 0
fi
group_pk="$(lookup_group_pk "$group_name")"
if [[ -z "$group_pk" ]]; then
echo "error: could not resolve Authentik group ${group_name}" >&2
exit 1
fi
existing="$(
api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \
| jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \
| head -n1
)"
payload="$(
jq -cn \
--arg target "$application_pk" \
--arg group "$group_pk" \
'{
group: $group,
target: $target,
negate: false,
enabled: true,
order: 100,
timeout: 30,
failure_result: false
}'
)"
if [[ -n "$existing" ]]; then
binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')"
api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null
else
api POST "/api/v3/policies/bindings/" "$payload" >/dev/null
fi
}
wait_for_authentik
template_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \
| head -n1
)"
if [[ -z "$template_provider" ]]; then
echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2
exit 1
fi
authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')"
invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')"
property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')"
signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')"
provider_payload="$(
jq -n \
--arg name "$provider_name" \
--arg authorization_flow "$authorization_flow" \
--arg invalidation_flow "$invalidation_flow" \
--arg client_id "$client_id" \
--arg client_secret "$client_secret" \
--arg signing_key "$signing_key" \
--argjson property_mappings "$property_mappings" \
--argjson redirect_uris "$redirect_uris_json" \
'{
name: $name,
authorization_flow: $authorization_flow,
invalidation_flow: $invalidation_flow,
client_type: "confidential",
client_id: $client_id,
client_secret: $client_secret,
include_claims_in_id_token: true,
redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})),
property_mappings: $property_mappings,
signing_key: $signing_key,
issuer_mode: "per_provider",
sub_mode: "hashed_user_id"
}'
)"
existing_provider="$(
api GET "/api/v3/providers/oauth2/?page_size=200" \
| jq -c \
--arg application_slug "$application_slug" \
--arg provider_name "$provider_name" \
'.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \
| head -n1
)"
if [[ -n "$existing_provider" ]]; then
provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')"
api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null
else
provider_pk="$(
api POST "/api/v3/providers/oauth2/" "$provider_payload" \
| jq -r '.pk // empty'
)"
fi
if [[ -z "${provider_pk:-}" ]]; then
echo "error: Grafana OIDC provider did not return a primary key" >&2
exit 1
fi
application_payload="$(
jq -n \
--arg name "$application_name" \
--arg slug "$application_slug" \
--arg provider "$provider_pk" \
--arg launch_url "$launch_url" \
'{
name: $name,
slug: $slug,
provider: ($provider | tonumber),
meta_launch_url: $launch_url,
open_in_new_tab: false,
policy_engine_mode: "any"
}'
)"
existing_application="$(
api GET "/api/v3/core/applications/?page_size=200" \
| jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \
| head -n1
)"
if [[ -n "$existing_application" ]]; then
application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')"
else
create_application_result="$(
api_with_status POST "/api/v3/core/applications/" "$application_payload"
)"
create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')"
create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')"
if [[ "$create_application_status" =~ ^20[01]$ ]]; then
application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')"
elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e '
(.slug // [] | index("Application with this slug already exists.")) != null
or (.provider // [] | index("Application with this provider already exists.")) != null
' >/dev/null; then
application_pk="existing-duplicate"
else
printf '%s\n' "$create_application_body" >&2
echo "error: could not reconcile Authentik application ${application_slug}" >&2
exit 1
fi
fi
if [[ -z "${application_pk:-}" ]]; then
echo "error: Grafana OIDC application did not return a primary key" >&2
exit 1
fi
if [[ -n "$access_group" ]]; then
ensure_application_group_binding "$application_slug" "$access_group"
fi
for _ in $(seq 1 30); do
if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then
echo "Synced Authentik Grafana OIDC application ${application_slug} (${application_name})."
exit 0
fi
sleep 2
done
echo "warning: Grafana OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2
echo "Synced Authentik Grafana OIDC application ${application_slug} (${application_name})."

View file

@ -75,6 +75,7 @@ set -euo pipefail
base_services=( base_services=(
forgejo.service forgejo.service
grafana.service
caddy.service caddy.service
burrow-forgejo-bootstrap.service burrow-forgejo-bootstrap.service
burrow-forgejo-runner-bootstrap.service burrow-forgejo-runner-bootstrap.service
@ -89,6 +90,7 @@ nsc_services=(
tailnet_services=( tailnet_services=(
burrow-authentik-runtime.service burrow-authentik-runtime.service
burrow-authentik-ready.service burrow-authentik-ready.service
burrow-authentik-grafana-oidc.service
headscale.service headscale.service
headscale-bootstrap.service headscale-bootstrap.service
) )
@ -161,6 +163,8 @@ if [[ "${EXPECT_TAILNET}" == "1" ]]; then
echo "== agenix ==" echo "== agenix =="
ls -l /run/agenix || true ls -l /run/agenix || true
test -s /run/agenix/burrowAuthentikEnv test -s /run/agenix/burrowAuthentikEnv
test -s /run/agenix/burrowGrafanaAdminPassword
test -s /run/agenix/burrowGrafanaOidcClientSecret
test -s /run/agenix/burrowHeadscaleOidcClientSecret test -s /run/agenix/burrowHeadscaleOidcClientSecret
fi fi
@ -177,6 +181,7 @@ if command -v curl >/dev/null 2>&1; then
curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login curl -fsS -o /dev/null -w 'forgejo_login %{http_code}\n' http://127.0.0.1:3000/user/login
curl -fsS -o /dev/null -H 'Host: burrow.net' -w 'burrow_root %{http_code}\n' http://127.0.0.1/ curl -fsS -o /dev/null -H 'Host: burrow.net' -w 'burrow_root %{http_code}\n' http://127.0.0.1/
curl -fsS -o /dev/null -H 'Host: git.burrow.net' -w 'git_login %{http_code}\n' http://127.0.0.1/user/login curl -fsS -o /dev/null -H 'Host: git.burrow.net' -w 'git_login %{http_code}\n' http://127.0.0.1/user/login
curl -fsS -o /dev/null -H 'Host: graphs.burrow.net' -w 'grafana_login %{http_code}\n' http://127.0.0.1/login
if [[ "${EXPECT_TAILNET}" == "1" ]]; then if [[ "${EXPECT_TAILNET}" == "1" ]]; then
curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/ curl -fsS -o /dev/null -H 'Host: auth.burrow.net' -w 'authentik_ready %{http_code}\n' http://127.0.0.1/-/health/ready/
curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true curl -sS -o /dev/null -H 'Host: ts.burrow.net' -w 'headscale_root %{http_code}\n' http://127.0.0.1/ || true

View file

@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}"
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage backup}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage backup}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI is required for Garage backup" >&2
exit 127
fi
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS backup" >&2
exit 127
fi
if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then
Scripts/ci/google-wif-auth.sh
fi
export AWS_DEFAULT_REGION="${BURROW_GARAGE_REGION:-garage}"
export AWS_EC2_METADATA_DISABLED=true
requested_releases=0
requested_packages=0
requested_nix_cache=0
IFS=',' read -r -a requested_items <<< "${BURROW_GARAGE_BACKUP_SET:-all}"
for raw_item in "${requested_items[@]}"; do
item="${raw_item//[[:space:]]/}"
case "$item" in
"" )
;;
all )
requested_releases=1
requested_packages=1
requested_nix_cache=1
;;
release|releases )
requested_releases=1
;;
package|packages )
requested_packages=1
;;
attic|nix|nix-cache|nix_cache )
requested_nix_cache=1
;;
* )
echo "unknown Garage backup set item: $item" >&2
exit 64
;;
esac
done
if [[ "$requested_releases" == "0" && "$requested_packages" == "0" && "$requested_nix_cache" == "0" ]]; then
echo "no Garage backup buckets selected" >&2
exit 1
fi
work_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-garage-backup"
rsync_flags=(--recursive)
if [[ "${BURROW_GARAGE_BACKUP_DELETE:-false}" == "true" ]]; then
rsync_flags+=(--delete-unmatched-destination-objects)
fi
sync_bucket() {
local name="$1"
local garage_bucket="$2"
local gcs_bucket="$3"
local source_dir="${work_root}/${name}"
rm -rf "$source_dir"
mkdir -p "$source_dir"
aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "s3://${garage_bucket}" "$source_dir" --no-progress
gcloud storage rsync "${rsync_flags[@]}" "$source_dir" "gs://${gcs_bucket}"
}
if [[ "$requested_releases" == "1" ]]; then
sync_bucket \
releases \
"${BURROW_RELEASE_GARAGE_BUCKET:-burrow-releases}" \
"${BURROW_RELEASE_GCS_BUCKET:-burrow-net-releases}"
fi
if [[ "$requested_packages" == "1" ]]; then
sync_bucket \
packages \
"${BURROW_PACKAGE_GARAGE_BUCKET:-burrow-packages}" \
"${BURROW_PACKAGE_GCS_BUCKET:-burrow-net-packages}"
fi
if [[ "$requested_nix_cache" == "1" ]]; then
sync_bucket \
nix-cache \
"${BURROW_NIX_CACHE_GARAGE_BUCKET:-attic}" \
"${BURROW_NIX_CACHE_GCS_BUCKET:-burrow-net-nix-cache}"
fi

View file

@ -4,17 +4,8 @@ set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}" cd "${repo_root}"
release_ref="${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}" build_number="${BUILD_NUMBER:-${RELEASE_REF:-manual-${GITHUB_SHA:-unknown}}}"
target="x86_64-unknown-linux-gnu" export BUILD_NUMBER="$build_number"
out_dir="${repo_root}/dist" export BURROW_RELEASE_OUT="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}"
staging="${out_dir}/burrow-${release_ref}-${target}"
mkdir -p "${staging}" Scripts/ci/package-release-artifacts.sh "${1:-all}"
cargo build --locked --release -p burrow --bin burrow
install -m 0755 target/release/burrow "${staging}/burrow"
cp README.md "${staging}/README.md"
tarball="${out_dir}/burrow-${release_ref}-${target}.tar.gz"
tar -C "${out_dir}" -czf "${tarball}" "$(basename "${staging}")"
shasum -a 256 "${tarball}" > "${tarball}.sha256"

View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "missing required environment variable: ${name}" >&2
return 1
fi
}
require_file_or_env() {
local file_name="$1"
local env_name="$2"
if [[ -n "${!file_name:-}" && -f "${!file_name:-}" ]]; then
return 0
fi
if [[ -n "${!env_name:-}" ]]; then
return 0
fi
echo "missing required ${file_name} file or ${env_name} environment value" >&2
return 1
}
case "${1:-all}" in
gcs)
require_env BURROW_GOOGLE_PROJECT_ID
require_env BURROW_GOOGLE_PROJECT_NUMBER
require_env BURROW_GOOGLE_WIF_POOL_ID
require_env BURROW_GOOGLE_WIF_PROVIDER_ID
require_env BURROW_GOOGLE_WIF_SERVICE_ACCOUNT
require_env BURROW_AUTHENTIK_WIF_ISSUER
require_env BURROW_AUTHENTIK_WIF_AUDIENCE
require_env BURROW_AUTHENTIK_WIF_CLIENT_ID
if [[ -z "${BURROW_AUTHENTIK_WIF_TOKEN:-}" && -z "${BURROW_AUTHENTIK_WIF_CLIENT_SECRET:-}" ]]; then
if [[ -z "${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}" || ! -f "${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}" ]]; then
echo "missing Authentik WIF token, token file, or client secret" >&2
exit 1
fi
fi
;;
app-store)
require_env ASC_API_KEY_ID
require_env ASC_API_ISSUER_ID
require_file_or_env ASC_API_KEY_PATH ASC_API_KEY_BASE64
;;
apple-signing)
require_file_or_env APPLE_SIGNING_CERTIFICATE_PATH APPLE_SIGNING_CERTIFICATE_BASE64
require_env APPLE_SIGNING_CERTIFICATE_PASSWORD
;;
sparkle)
"$0" gcs
;;
microsoft-store)
require_env MICROSOFT_TENANT_ID
require_env MICROSOFT_CLIENT_ID
require_env MICROSOFT_CLIENT_SECRET
;;
all)
"$0" gcs
"$0" app-store
"$0" apple-signing
;;
*)
echo "unknown release config group: $1" >&2
exit 64
;;
esac

View file

@ -143,13 +143,18 @@ if [[ -e "${config_file}" && ! -w "${config_file}" ]]; then
fi fi
mkdir -p "$(dirname -- "${config_file}")" mkdir -p "$(dirname -- "${config_file}")"
cat > "${config_file}" <<'EOF' {
experimental-features = nix-command flakes echo "experimental-features = nix-command flakes"
sandbox = true echo "sandbox = true"
fallback = true echo "fallback = true"
substituters = https://cache.nixos.org if [[ -n "${BURROW_NIX_CACHE_PUBLIC_KEY:-}" ]]; then
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= echo "substituters = ${BURROW_NIX_CACHE_URL:-https://nix.burrow.net/${BURROW_NIX_CACHE_NAME:-burrow}} https://cache.nixos.org"
EOF echo "trusted-public-keys = ${BURROW_NIX_CACHE_PUBLIC_KEY} cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
else
echo "substituters = https://cache.nixos.org"
echo "trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
fi
} > "${config_file}"
command -v nix >/dev/null 2>&1 || { command -v nix >/dev/null 2>&1 || {
echo "nix is still unavailable after bootstrap" >&2 echo "nix is still unavailable after bootstrap" >&2

73
Scripts/ci/ensure-nsc.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v nsc >/dev/null 2>&1; then
nsc version || true
exit 0
fi
if ! command -v curl >/dev/null 2>&1; then
echo "::error ::curl is required to install nsc" >&2
exit 1
fi
if ! command -v tar >/dev/null 2>&1; then
echo "::error ::tar is required to install nsc" >&2
exit 1
fi
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*)
echo "::error ::Unsupported arch for nsc: ${arch}" >&2
exit 1
;;
esac
if [[ "$os" != "linux" ]]; then
echo "::warning ::nsc bootstrap is only supported on Linux; relying on the runner or Nix shell for ${os}."
exit 1
fi
version="${NSC_VERSION:-0.0.506}"
expected_sha256=""
case "${version}/${arch}" in
0.0.506/amd64) expected_sha256="4a093e0269878a9514e7c00d99b88a924f307ad15fb9d1595ee5f6582f99a0f0" ;;
0.0.506/arm64) expected_sha256="6acf074b67fcbaaf59a4437f6bd40da7e38438c427849c1b5fcf4621ce75aee2" ;;
esac
asset="nsc_${version}_linux_${arch}.tar.gz"
url="https://get.namespace.so/packages/nsc/v${version}/${asset}"
tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-nsc.$$"
install_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/nsc-bin"
mkdir -p "$tmp_dir" "$install_dir"
trap 'rm -rf "$tmp_dir"' EXIT
curl -fsSL -o "${tmp_dir}/${asset}" "$url"
if [[ -n "$expected_sha256" ]] && command -v sha256sum >/dev/null 2>&1; then
actual_sha256="$(sha256sum "${tmp_dir}/${asset}" | awk '{print $1}')"
if [[ "$actual_sha256" != "$expected_sha256" ]]; then
echo "::error ::nsc ${asset} sha256 mismatch: expected ${expected_sha256}, got ${actual_sha256}" >&2
exit 1
fi
fi
tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir"
for bin in nsc bazel-credential-nsc docker-credential-nsc; do
if [[ -x "${tmp_dir}/${bin}" ]]; then
install -m 0755 "${tmp_dir}/${bin}" "${install_dir}/${bin}"
fi
done
if [[ ! -x "${install_dir}/nsc" || ! -x "${install_dir}/bazel-credential-nsc" ]]; then
echo "::error ::Failed to install nsc and bazel-credential-nsc from ${asset}" >&2
exit 1
fi
if [[ -n "${GITHUB_PATH:-}" ]]; then
echo "${install_dir}" >> "${GITHUB_PATH}"
fi
export PATH="${install_dir}:$PATH"
nsc version || true

60
Scripts/ci/ensure-spacectl.sh Executable file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
if command -v spacectl >/dev/null 2>&1; then
spacectl version || true
exit 0
fi
if ! command -v curl >/dev/null 2>&1; then
echo "::error ::curl is required to install spacectl" >&2
exit 1
fi
if ! command -v tar >/dev/null 2>&1; then
echo "::error ::tar is required to install spacectl" >&2
exit 1
fi
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*)
echo "::error ::Unsupported arch for spacectl: ${arch}" >&2
exit 1
;;
esac
if [[ "$os" != "linux" && "$os" != "darwin" ]]; then
echo "::error ::Unsupported OS for spacectl: ${os}" >&2
exit 1
fi
version="${SPACECTL_VERSION:-v0.5.0}"
version_num="${version#v}"
asset="spacectl_${version_num}_${os}_${arch}.tar.gz"
url="https://github.com/namespacelabs/spacectl/releases/download/${version}/${asset}"
tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-spacectl.$$"
install_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/spacectl-bin"
mkdir -p "$tmp_dir" "$install_dir"
trap 'rm -rf "$tmp_dir"' EXIT
curl -fsSL -o "${tmp_dir}/${asset}" "$url"
tar -xzf "${tmp_dir}/${asset}" -C "$tmp_dir"
bin="${tmp_dir}/spacectl"
if [[ ! -x "$bin" ]]; then
bin="$(find "$tmp_dir" -maxdepth 3 -type f -name spacectl -perm -111 | head -n1 || true)"
fi
if [[ -z "${bin}" || ! -x "${bin}" ]]; then
echo "::error ::Failed to locate spacectl binary in ${asset}" >&2
exit 1
fi
install -m 0755 "$bin" "${install_dir}/spacectl"
if [[ -n "${GITHUB_PATH:-}" ]]; then
echo "${install_dir}" >> "${GITHUB_PATH}"
fi
export PATH="${install_dir}:$PATH"
spacectl version || true

126
Scripts/ci/google-wif-auth.sh Executable file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
project_id="${GOOGLE_CLOUD_PROJECT:-${BURROW_GOOGLE_PROJECT_ID:-project-88c23ce9-918a-470a-b33}}"
project_number="${BURROW_GOOGLE_PROJECT_NUMBER:-416198671487}"
pool_id="${BURROW_GOOGLE_WIF_POOL_ID:-burrow-authentik}"
provider_id="${BURROW_GOOGLE_WIF_PROVIDER_ID:-forgejo-runners}"
service_account="${BURROW_GOOGLE_WIF_SERVICE_ACCOUNT:-burrow-forgejo-runner@project-88c23ce9-918a-470a-b33.iam.gserviceaccount.com}"
authentik_issuer="${BURROW_AUTHENTIK_WIF_ISSUER:-https://auth.burrow.net/application/o/google-cloud/}"
authentik_audience="${BURROW_AUTHENTIK_WIF_AUDIENCE:-google-wif.burrow.net}"
authentik_client_id="${BURROW_AUTHENTIK_WIF_CLIENT_ID:-${AUTHENTIK_GOOGLE_WIF_CLIENT_ID:-google-wif.burrow.net}}"
authentik_client_secret="${BURROW_AUTHENTIK_WIF_CLIENT_SECRET:-${AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET:-}}"
authentik_token_file="${BURROW_AUTHENTIK_WIF_TOKEN_FILE:-}"
authentik_token="${BURROW_AUTHENTIK_WIF_TOKEN:-}"
work_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-google-wif"
emit_env=1
usage() {
cat <<'EOF'
Usage: Scripts/ci/google-wif-auth.sh [--no-emit-env]
Authenticates gcloud to the Burrow Google project through Authentik-issued OIDC
and Google Workload Identity Federation. No Google service-account JSON key is
used or written.
Inputs:
GOOGLE_CLOUD_PROJECT / BURROW_GOOGLE_PROJECT_ID
BURROW_GOOGLE_PROJECT_NUMBER
BURROW_GOOGLE_WIF_POOL_ID
BURROW_GOOGLE_WIF_PROVIDER_ID
BURROW_GOOGLE_WIF_SERVICE_ACCOUNT
BURROW_AUTHENTIK_WIF_ISSUER
BURROW_AUTHENTIK_WIF_AUDIENCE
BURROW_AUTHENTIK_WIF_TOKEN or BURROW_AUTHENTIK_WIF_TOKEN_FILE
If no token is supplied, the script requests one from Authentik using
BURROW_AUTHENTIK_WIF_CLIENT_ID and BURROW_AUTHENTIK_WIF_CLIENT_SECRET.
EOF
}
while [[ "$#" -gt 0 ]]; do
case "$1" in
--no-emit-env)
emit_env=0
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
exit 64
;;
esac
shift
done
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$1 is required for Google WIF authentication" >&2
exit 127
fi
}
require_command gcloud
require_command curl
require_command jq
mkdir -p "$work_dir"
chmod 0700 "$work_dir"
token_path="$work_dir/authentik-oidc-token.jwt"
credential_config="$work_dir/google-wif-credentials.json"
if [[ -n "$authentik_token_file" ]]; then
if [[ ! -s "$authentik_token_file" ]]; then
echo "Authentik WIF token file is empty or missing: $authentik_token_file" >&2
exit 1
fi
cp "$authentik_token_file" "$token_path"
elif [[ -n "$authentik_token" ]]; then
printf '%s' "$authentik_token" > "$token_path"
else
if [[ -z "$authentik_client_secret" ]]; then
echo "missing Authentik WIF token. Set BURROW_AUTHENTIK_WIF_TOKEN, BURROW_AUTHENTIK_WIF_TOKEN_FILE, or BURROW_AUTHENTIK_WIF_CLIENT_SECRET." >&2
exit 1
fi
token_endpoint="${authentik_issuer%/}/token/"
curl -fsS \
-u "${authentik_client_id}:${authentik_client_secret}" \
-d grant_type=client_credentials \
-d scope="openid profile email groups" \
-d audience="$authentik_audience" \
"$token_endpoint" \
| jq -r '.id_token // empty' > "$token_path"
fi
chmod 0600 "$token_path"
if [[ ! -s "$token_path" ]]; then
echo "Authentik did not return an OIDC token for Google WIF" >&2
exit 1
fi
provider_resource="projects/${project_number}/locations/global/workloadIdentityPools/${pool_id}/providers/${provider_id}"
gcloud iam workload-identity-pools create-cred-config "$provider_resource" \
--service-account "$service_account" \
--subject-token-type="urn:ietf:params:oauth:token-type:jwt" \
--credential-source-file "$token_path" \
--output-file "$credential_config"
chmod 0600 "$credential_config"
gcloud auth login --cred-file="$credential_config" --brief
gcloud config set project "$project_id" >/dev/null
{
echo "GOOGLE_APPLICATION_CREDENTIALS=$credential_config"
echo "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE=$credential_config"
echo "CLOUDSDK_CORE_PROJECT=$project_id"
echo "GOOGLE_CLOUD_PROJECT=$project_id"
} > "$work_dir/google-wif.env"
if [[ "$emit_env" == "1" && -n "${GITHUB_ENV:-}" ]]; then
cat "$work_dir/google-wif.env" >> "$GITHUB_ENV"
fi
echo "Authenticated gcloud through Authentik WIF as ${service_account} in ${project_id}."

View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
signing_ready=0
keychain_path=""
write_env() {
local key="$1"
local value="$2"
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "${key}=${value}" >> "$GITHUB_ENV"
fi
}
install_profile_file() {
local source="$1"
[[ -f "$source" ]] || return 0
local uuid
uuid="$(security cms -D -i "$source" 2>/dev/null | plutil -extract UUID raw -o - - 2>/dev/null || true)"
if [[ -z "$uuid" ]]; then
uuid="$(basename "$source")"
fi
local profiles_dir="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$profiles_dir"
cp "$source" "${profiles_dir}/${uuid}.provisionprofile"
echo "Installed provisioning profile ${uuid}."
}
install_profile_base64() {
local name="$1"
local value="${!name:-}"
[[ -n "$value" ]] || return 0
local out="${RUNNER_TEMP:-/tmp}/${name}.provisionprofile"
printf '%s' "$value" | base64 --decode > "$out"
install_profile_file "$out"
}
install_profile_env_path() {
local name="$1"
local path="${!name:-}"
[[ -n "$path" ]] || return 0
if [[ ! -f "$path" ]]; then
echo "::error ::${name} points to a missing provisioning profile: ${path}" >&2
exit 1
fi
install_profile_file "$path"
}
for profile in \
Apple/Profiles/*.provisionprofile \
Apple/Profiles/*.mobileprovision \
.forgejo/actions/export/*.provisionprofile \
.forgejo/actions/export/*.mobileprovision; do
[[ -e "$profile" ]] || continue
install_profile_file "$profile"
done
install_profile_env_path APPLE_PROVISIONING_PROFILE_PATH
install_profile_env_path APPLE_NETWORK_EXTENSION_PROVISIONING_PROFILE_PATH
install_profile_env_path APPLE_MACOS_PROVISIONING_PROFILE_PATH
install_profile_base64 APPLE_PROVISIONING_PROFILE_BASE64
install_profile_base64 APPLE_NETWORK_EXTENSION_PROVISIONING_PROFILE_BASE64
install_profile_base64 APPLE_MACOS_PROVISIONING_PROFILE_BASE64
cert_base64="${APPLE_SIGNING_CERTIFICATE_BASE64:-${APPLE_DISTRIBUTION_CERTIFICATE_BASE64:-}}"
cert_path_env="${APPLE_SIGNING_CERTIFICATE_PATH:-${APPLE_DISTRIBUTION_CERTIFICATE_PATH:-}}"
cert_password="${APPLE_SIGNING_CERTIFICATE_PASSWORD:-${APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD:-}}"
keychain_password="${APPLE_KEYCHAIN_PASSWORD:-${cert_password}}"
if [[ -n "$cert_base64" || -n "$cert_path_env" ]]; then
if [[ -z "$cert_password" ]]; then
echo "::error ::APPLE_SIGNING_CERTIFICATE_PASSWORD is required when Apple signing certificate material is set." >&2
exit 1
fi
cert_path="${RUNNER_TEMP:-/tmp}/burrow-apple-signing.p12"
keychain_path="$HOME/Library/Keychains/BurrowRelease.keychain-db"
if [[ -n "$cert_path_env" ]]; then
if [[ ! -f "$cert_path_env" ]]; then
echo "::error ::APPLE_SIGNING_CERTIFICATE_PATH points to a missing file: ${cert_path_env}" >&2
exit 1
fi
cp "$cert_path_env" "$cert_path"
else
printf '%s' "$cert_base64" | base64 --decode > "$cert_path"
fi
security create-keychain -p "$keychain_password" "$keychain_path" || true
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$cert_path" \
-k "$keychain_path" \
-P "$cert_password" \
-T /usr/bin/codesign \
-T /usr/bin/security \
-T /usr/bin/productbuild \
-T /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" >/dev/null
existing="$(security list-keychains -d user | sed 's/[ "]//g' || true)"
if ! printf '%s\n' "$existing" | grep -Fxq "$keychain_path"; then
security list-keychains -d user -s "$keychain_path" $existing
fi
signing_ready=1
write_env BURROW_APPLE_KEYCHAIN_PATH "$keychain_path"
else
identities="$(security find-identity -v -p codesigning 2>/dev/null || true)"
if printf '%s\n' "$identities" | grep -Eq '"(Apple Distribution|Developer ID Application):'; then
signing_ready=1
fi
fi
write_env BURROW_APPLE_SIGNING_READY "$signing_ready"
if [[ "$signing_ready" == "1" ]]; then
echo "Apple signing assets are available."
else
echo "::notice ::Apple signing certificate is not configured; release lane will build unsigned validation artifacts only."
fi

225
Scripts/ci/nscloud-cache.sh Executable file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -eq 0 ]]; then
echo "No cache modes specified; skipping."
exit 0
fi
want_bazel_cache=0
for mode in "$@"; do
if [[ "$mode" == "bazel" ]]; then
want_bazel_cache=1
break
fi
done
portable_mkdir() {
hash -r >/dev/null 2>&1 || true
command -p mkdir "$@"
}
append_bazel_storage_cache() {
local bazelrc_path="$1"
local cache_root="${BURROW_BAZEL_STORAGE_CACHE:-}"
if [[ -z "$cache_root" && -n "${NSC_CACHE_PATH:-}" ]]; then
cache_root="${NSC_CACHE_PATH}/bazel"
fi
if [[ -z "$cache_root" ]]; then
return 0
fi
portable_mkdir -p "${cache_root}/disk" "${cache_root}/repository"
{
echo "build --disk_cache=${cache_root}/disk"
echo "build --repository_cache=${cache_root}/repository"
} >> "$bazelrc_path"
echo "Namespace Bazel storage cache configured: ${cache_root}"
}
configure_bazel_remote_cache() {
if [[ "$want_bazel_cache" != "1" ]]; then
return 0
fi
export BURROW_BAZEL_NSCCACHE_ACTIVE=0
if ! command -v nsc >/dev/null 2>&1; then
if bash Scripts/ci/ensure-nsc.sh; then
nsc_bin_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/nsc-bin"
if [[ -x "${nsc_bin_dir}/nsc" ]]; then
export PATH="${nsc_bin_dir}:$PATH"
fi
else
echo "::warning ::nsc not found; using local Bazel disk cache only."
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "BURROW_BAZEL_NSCCACHE_ACTIVE=0" >> "$GITHUB_ENV"
fi
return 0
fi
fi
local bazelrc_path
bazelrc_path="${NSC_BAZELRC_PATH:-${TMPDIR:-/tmp}/burrow-nsc-cache.bazelrc}"
portable_mkdir -p "$(dirname "$bazelrc_path")"
if nsc auth check-login >/dev/null 2>&1 && nsc cache bazel setup --bazelrc "$bazelrc_path" >/dev/null 2>&1; then
append_bazel_storage_cache "$bazelrc_path"
export BURROW_BAZEL_NSCCACHE_BAZELRC="$bazelrc_path"
export BURROW_BAZEL_NSCCACHE_ACTIVE=1
echo "Namespace Bazel cache configured: $bazelrc_path"
if [[ -n "${GITHUB_ENV:-}" ]]; then
{
echo "BURROW_BAZEL_NSCCACHE_ACTIVE=1"
echo "BURROW_BAZEL_NSCCACHE_BAZELRC=$bazelrc_path"
echo "NSC_BAZELRC_PATH=$bazelrc_path"
} >> "$GITHUB_ENV"
fi
else
append_bazel_storage_cache "$bazelrc_path"
echo "::warning ::Namespace Bazel remote cache unavailable; using local Bazel repository/disk cache."
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "BURROW_BAZEL_NSCCACHE_ACTIVE=0" >> "$GITHUB_ENV"
echo "BURROW_BAZEL_NSCCACHE_BAZELRC=$bazelrc_path" >> "$GITHUB_ENV"
echo "NSC_BAZELRC_PATH=$bazelrc_path" >> "$GITHUB_ENV"
fi
fi
}
cache_root="${NSC_CACHE_PATH:-}"
if [[ -z "$cache_root" ]]; then
ensure_dir() {
local dir="$1"
if portable_mkdir -p "$dir" 2>/dev/null; then
return 0
fi
if [[ "$(id -u)" != "0" ]] && command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
sudo mkdir -p "$dir" >/dev/null 2>&1 || return 1
return 0
fi
return 1
}
if [[ "$(uname -s 2>/dev/null || true)" == "Linux" ]]; then
tmp_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/burrow-nscloud-cache"
for candidate in "/cache/nscloud" "$tmp_root" "$PWD/.nscloud-cache"; do
if ensure_dir "$candidate"; then
cache_root="$candidate"
break
fi
done
elif ensure_dir "$PWD/.nscloud-cache"; then
cache_root="$PWD/.nscloud-cache"
fi
fi
if [[ -z "$cache_root" ]]; then
echo "NSCloud cache volume not configured (NSC_CACHE_PATH missing); skipping."
configure_bazel_remote_cache
exit 0
fi
export NSC_CACHE_PATH="$cache_root"
if [[ -n "${GITHUB_ENV:-}" ]]; then
echo "NSC_CACHE_PATH=$cache_root" >> "$GITHUB_ENV"
fi
spacectl_bin_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/spacectl-bin"
if ! command -v spacectl >/dev/null 2>&1; then
if ! bash Scripts/ci/ensure-spacectl.sh; then
echo "::warning ::Unable to install spacectl; skipping filesystem cache mount."
configure_bazel_remote_cache
exit 0
fi
if [[ -x "${spacectl_bin_dir}/spacectl" ]]; then
export PATH="${spacectl_bin_dir}:$PATH"
fi
fi
mode_args=()
for mode in "$@"; do
[[ -z "$mode" ]] && continue
if [[ "$mode" == "bazel" ]]; then
continue
fi
if [[ "$mode" == "nix" && "${BURROW_NSC_DIRECT_NIX_VOLUME:-0}" == "1" ]]; then
echo "Namespace Nix cache volume is mounted directly at /nix; skipping spacectl nix mount."
continue
fi
if [[ "$mode" == "nix" && "$(uname -s)" == "Darwin" ]]; then
echo "Skipping late Nix cache mount on macOS; using existing Nix profile and Bazel cache."
continue
fi
mode_args+=("--mode=${mode}")
done
if [[ "${#mode_args[@]}" -eq 0 ]]; then
echo "No filesystem cache modes require mounting; skipping spacectl cache mount."
configure_bazel_remote_cache
exit 0
fi
portable_mkdir -p "$cache_root" 2>/dev/null || true
args=(-o json cache mount "--dry_run=false" "--cache_root=${cache_root}" "${mode_args[@]}")
echo "Mounting NSCloud cache: spacectl ${args[*]//${cache_root}/<cache_root>}"
spacectl_bin="$(command -v spacectl)"
run_mount=("$spacectl_bin" "${args[@]}")
if [[ "$(id -u)" != "0" ]]; then
if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then
echo "::warning ::spacectl cache mount requires passwordless sudo; skipping filesystem cache."
configure_bazel_remote_cache
exit 0
fi
run_mount=(sudo -n env "PATH=$PATH" "$spacectl_bin" "${args[@]}")
fi
tmp_err="$(mktemp "${TMPDIR:-/tmp}/spacectl.XXXXXX")"
cleanup() {
rm -f "$tmp_err"
}
trap cleanup EXIT
json=""
if ! json="$("${run_mount[@]}" 2>"$tmp_err")"; then
echo "::warning ::spacectl cache mount failed; skipping filesystem cache."
if [[ -n "$json" ]]; then
printf '%s\n' "$json" || true
fi
if [[ -s "$tmp_err" ]]; then
sed -n '1,200p' "$tmp_err" || true
fi
configure_bazel_remote_cache
exit 0
fi
if command -v python3 >/dev/null 2>&1; then
NSC_MOUNT_JSON="$json" python3 - <<'PY'
import json
import os
import sys
raw = os.environ.get("NSC_MOUNT_JSON", "").strip()
if not raw:
sys.exit(0)
try:
payload = json.loads(raw)
except Exception as exc:
print(f"::warning ::Unable to parse spacectl JSON output; skipping cache env export: {exc}")
sys.exit(0)
if payload.get("error"):
print(f"::warning ::spacectl cache mount skipped: {payload.get('message') or 'error=true'}")
sys.exit(0)
add_envs = (payload.get("output") or {}).get("add_envs") or {}
gh_env = os.environ.get("GITHUB_ENV")
if gh_env and isinstance(add_envs, dict):
with open(gh_env, "a", encoding="utf-8") as f:
for key, value in add_envs.items():
if key and value is not None:
f.write(f"{key}={value}\n")
PY
fi
configure_bazel_remote_cache

View file

@ -0,0 +1,287 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
platform="${1:-all}"
build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}"
out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}"
apple_root="${out_root}/apple"
archives_root="${apple_root}/archives"
derived_data="${BURROW_DERIVED_DATA_PATH:-${repo_root}/.derived-data/apple}"
source_packages="${BURROW_SWIFTPM_CACHE_PATH:-${XDG_CACHE_HOME:-${HOME}/.cache}/burrow/swiftpm}"
project="Apple/Burrow.xcodeproj"
scheme="${BURROW_APPLE_SCHEME:-App}"
bundle_id="${BURROW_APPLE_BUNDLE_ID:-com.hackclub.burrow}"
network_extension_bundle_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${bundle_id}.network}"
signing_ready="${BURROW_APPLE_SIGNING_READY:-0}"
require_signing="${BURROW_APPLE_REQUIRE_SIGNING:-false}"
sparkle_feed_url="${BURROW_SPARKLE_FEED_URL:-https://releases.burrow.net/sparkle/appcast.xml}"
sparkle_public_ed_key="${BURROW_SPARKLE_PUBLIC_ED_KEY:-Myv9ZNZT6YGKMtMezh52ra4WqaeEKc4VlvVU0evhJeI=}"
sparkle_sign_with_kms="${BURROW_SPARKLE_SIGN_WITH_KMS:-false}"
mkdir -p "$apple_root" "$archives_root" "$derived_data" "$source_packages"
truthy() {
case "${1:-}" in
1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;;
*) return 1 ;;
esac
}
sha256_file() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" > "${file}.sha256"
else
sha256sum "$file" > "${file}.sha256"
fi
}
write_version() {
mkdir -p Apple/Configuration
printf 'CURRENT_PROJECT_VERSION = %s\n' "$build_number" > Apple/Configuration/Version.xcconfig
}
xcode_common_args=(
-project "$project"
-scheme "$scheme"
-configuration Release
-derivedDataPath "$derived_data"
-clonedSourcePackagesDirPath "$source_packages"
CURRENT_PROJECT_VERSION="$build_number"
APP_BUNDLE_IDENTIFIER="$bundle_id"
NETWORK_EXTENSION_BUNDLE_IDENTIFIER="$network_extension_bundle_id"
BURROW_SPARKLE_FEED_URL="$sparkle_feed_url"
BURROW_SPARKLE_PUBLIC_ED_KEY="$sparkle_public_ed_key"
)
xcode_auth_args=()
if [[ -n "${ASC_API_KEY_ID:-}" && -n "${ASC_API_ISSUER_ID:-}" && -n "${ASC_API_KEY_PATH:-}" && -f "${ASC_API_KEY_PATH:-}" ]]; then
xcode_auth_args+=(
-authenticationKeyID "$ASC_API_KEY_ID"
-authenticationKeyIssuerID "$ASC_API_ISSUER_ID"
-authenticationKeyPath "$ASC_API_KEY_PATH"
)
fi
if [[ -n "${BURROW_APPLE_KEYCHAIN_PATH:-}" ]]; then
xcode_common_args+=(OTHER_CODE_SIGN_FLAGS="--keychain ${BURROW_APPLE_KEYCHAIN_PATH}")
fi
require_xcodebuild() {
if ! command -v xcodebuild >/dev/null 2>&1; then
echo "xcodebuild is required for Apple release artifacts" >&2
exit 1
fi
xcodebuild -version
xcrun --find swiftc >/dev/null
}
unsigned_note() {
local platform_name="$1"
local artifact_name="$2"
{
echo "Burrow ${platform_name} signing assets were not available."
echo
echo "The lane built an unsigned validation artifact instead of an uploadable release artifact."
echo "Seal the Apple release credentials with agenix and let CI sync provisioning profiles from App Store Connect to produce ${artifact_name}."
} > "${apple_root}/README-${platform_name}-unsigned.txt"
}
ensure_signing_or_note() {
local platform_name="$1"
local artifact_name="$2"
if [[ "$signing_ready" == "1" ]]; then
return 0
fi
if truthy "$require_signing"; then
echo "Apple signing is required for ${platform_name}, but signing assets are not configured." >&2
exit 1
fi
unsigned_note "$platform_name" "$artifact_name"
return 1
}
write_export_options() {
local method="$1"
local path="$2"
cat > "$path" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>${method}</string>
<key>destination</key>
<string>export</string>
<key>signingStyle</key>
<string>automatic</string>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>${DEVELOPMENT_TEAM:-P6PV2R9443}</string>
</dict>
</plist>
EOF
}
build_ios_unsigned() {
local product_dir zip_path
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS Simulator" \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
product_dir="${derived_data}/Build/Products/Release-iphonesimulator"
zip_path="${apple_root}/Burrow-iOS-simulator-${build_number}.unsigned.zip"
if [[ -d "${product_dir}/Burrow.app" ]]; then
(cd "$product_dir" && zip -qr "$zip_path" Burrow.app)
sha256_file "$zip_path"
fi
}
build_ios_release() {
local archive_path export_dir export_options ipa
if ! ensure_signing_or_note "iOS" "Burrow-iOS-${build_number}.ipa"; then
build_ios_unsigned
return 0
fi
archive_path="${archives_root}/Burrow-iOS-${build_number}.xcarchive"
export_dir="${apple_root}/ios-export"
export_options="${apple_root}/ExportOptions-iOS.plist"
write_export_options "app-store-connect" "$export_options"
xcodebuild archive \
"${xcode_common_args[@]}" \
-destination "generic/platform=iOS" \
-archivePath "$archive_path" \
ARCHS=arm64 \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
xcodebuild -exportArchive \
-archivePath "$archive_path" \
-exportPath "$export_dir" \
-exportOptionsPlist "$export_options" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
ipa="$(find "$export_dir" -maxdepth 1 -type f -name '*.ipa' | sort | head -n 1 || true)"
if [[ -z "$ipa" ]]; then
echo "iOS export completed but did not produce an IPA" >&2
exit 1
fi
cp "$ipa" "${apple_root}/Burrow-iOS-${build_number}.ipa"
sha256_file "${apple_root}/Burrow-iOS-${build_number}.ipa"
}
build_macos_unsigned() {
local product_dir zip_path
xcodebuild build \
"${xcode_common_args[@]}" \
-destination "platform=macOS" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO
product_dir="${derived_data}/Build/Products/Release"
zip_path="${apple_root}/Burrow-macOS-${build_number}.unsigned.zip"
if [[ -d "${product_dir}/Burrow.app" ]]; then
ditto -c -k --keepParent "${product_dir}/Burrow.app" "$zip_path"
sha256_file "$zip_path"
fi
}
build_macos_release() {
local archive_path export_dir export_options app dmg channel sparkle_dir length pubdate url
if ! ensure_signing_or_note "macOS" "Burrow-macOS-${build_number}.dmg"; then
build_macos_unsigned
return 0
fi
archive_path="${archives_root}/Burrow-macOS-${build_number}.xcarchive"
export_dir="${apple_root}/macos-export"
export_options="${apple_root}/ExportOptions-macOS.plist"
write_export_options "${BURROW_MACOS_EXPORT_METHOD:-developer-id}" "$export_options"
xcodebuild archive \
"${xcode_common_args[@]}" \
-destination "generic/platform=macOS" \
-archivePath "$archive_path" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
xcodebuild -exportArchive \
-archivePath "$archive_path" \
-exportPath "$export_dir" \
-exportOptionsPlist "$export_options" \
-allowProvisioningUpdates \
"${xcode_auth_args[@]}"
app="$(find "$export_dir" -maxdepth 2 -type d -name 'Burrow.app' | sort | head -n 1 || true)"
if [[ -z "$app" ]]; then
echo "macOS export completed but did not produce Burrow.app" >&2
exit 1
fi
dmg="${apple_root}/Burrow-macOS-${build_number}.dmg"
rm -f "$dmg"
hdiutil create -volname "Burrow ${build_number}" -srcfolder "$app" -ov -format UDZO "$dmg"
sha256_file "$dmg"
channel="${SPARKLE_CHANNEL:-build-${build_number}}"
sparkle_dir="${out_root}/sparkle/${channel}"
mkdir -p "$sparkle_dir"
cp "$dmg" "$sparkle_dir/"
length="$(wc -c < "$dmg" | tr -d ' ')"
pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/$(basename "$dmg")"
cat > "${sparkle_dir}/appcast.xml" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Burrow ${channel}</title>
<item>
<title>Burrow ${build_number}</title>
<pubDate>${pubdate}</pubDate>
<sparkle:version>${build_number}</sparkle:version>
<sparkle:shortVersionString>0.1</sparkle:shortVersionString>
<enclosure url="${url}" sparkle:version="${build_number}" length="${length}" type="application/octet-stream" />
</item>
</channel>
</rss>
EOF
if truthy "$sparkle_sign_with_kms"; then
Scripts/sparkle/sign-appcast-kms.py \
--appcast "${sparkle_dir}/appcast.xml" \
--artifact-dir "$sparkle_dir"
fi
}
require_xcodebuild
write_version
case "$platform" in
all)
build_ios_release
build_macos_release
;;
ios)
build_ios_release
;;
macos)
build_macos_release
;;
*)
echo "unknown Apple release platform: $platform" >&2
exit 64
;;
esac
find "$out_root" -type f -print | sort

View file

@ -0,0 +1,332 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
platform="${1:-all}"
build_number="${BUILD_NUMBER:-${RELEASE_BUILD_NUMBER:-${RELEASE_REF:-manual}}}"
out_root="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${build_number}}"
mkdir -p "${out_root}"
sha256_file() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" > "${file}.sha256"
else
sha256sum "$file" > "${file}.sha256"
fi
}
burrow_crate_version() {
awk -F '"' '/^version = / { print $2; exit }' burrow/Cargo.toml
}
package_release_suffix() {
printf '%s' "$build_number" | tr -c 'A-Za-z0-9.+~' '.'
}
arch_package_suffix() {
printf '%s' "$build_number" | tr -c 'A-Za-z0-9.+_' '.'
}
deb_arch_for_target() {
case "$1" in
x86_64-unknown-linux-gnu) printf 'amd64' ;;
aarch64-unknown-linux-gnu) printf 'arm64' ;;
riscv64gc-unknown-linux-gnu) printf 'riscv64' ;;
*) return 1 ;;
esac
}
rpm_arch_for_target() {
case "$1" in
x86_64-unknown-linux-gnu) printf 'x86_64' ;;
aarch64-unknown-linux-gnu) printf 'aarch64' ;;
riscv64gc-unknown-linux-gnu) printf 'riscv64' ;;
*) return 1 ;;
esac
}
pacman_arch_for_target() {
case "$1" in
x86_64-unknown-linux-gnu) printf 'x86_64' ;;
aarch64-unknown-linux-gnu) printf 'aarch64' ;;
riscv64gc-unknown-linux-gnu) printf 'riscv64' ;;
*) return 1 ;;
esac
}
copy_license_readme() {
local dst="$1"
cp README.md "$dst/README.md"
if [[ -f LICENSE ]]; then
cp LICENSE "$dst/LICENSE"
elif [[ -f LICENSE.md ]]; then
cp LICENSE.md "$dst/LICENSE.md"
fi
}
package_deb() {
local target binary deb_arch pkg_version suffix staging deb_file installed_size
target="$1"
binary="$2"
if ! deb_arch="$(deb_arch_for_target "$target")"; then
echo "warning: no Debian architecture mapping for ${target}; skipping .deb package" >&2
return 0
fi
if ! command -v dpkg-deb >/dev/null 2>&1; then
echo "warning: dpkg-deb is not available; skipping .deb package" >&2
return 0
fi
suffix="$(package_release_suffix)"
pkg_version="${BURROW_PACKAGE_VERSION:-$(burrow_crate_version)+${suffix}}"
staging="${out_root}/linux/pkg-deb/burrow_${pkg_version}_${deb_arch}"
rm -rf "$staging"
mkdir -p \
"$staging/DEBIAN" \
"$staging/usr/bin" \
"$staging/usr/lib/systemd/system" \
"$staging/usr/share/doc/burrow" \
"$staging/usr/share/licenses/burrow"
install -m 0755 "$binary" "$staging/usr/bin/burrow"
install -m 0644 systemd/burrow.service "$staging/usr/lib/systemd/system/burrow.service"
install -m 0644 systemd/burrow.socket "$staging/usr/lib/systemd/system/burrow.socket"
install -m 0644 README.md "$staging/usr/share/doc/burrow/README.md"
install -m 0644 LICENSE.md "$staging/usr/share/licenses/burrow/LICENSE.md"
installed_size="$(du -sk "$staging/usr" | awk '{ print $1 }')"
cat > "$staging/DEBIAN/control" <<EOF
Package: burrow
Version: ${pkg_version}
Section: net
Priority: optional
Architecture: ${deb_arch}
Maintainer: Burrow Release Automation <packages@burrow.net>
Installed-Size: ${installed_size}
Depends: systemd
Description: Burrow VPN daemon and CLI
Burrow installs the daemon entrypoint and systemd socket used by the desktop UI.
EOF
deb_file="${out_root}/linux/burrow_${pkg_version}_${deb_arch}.deb"
dpkg-deb --build --root-owner-group "$staging" "$deb_file"
sha256_file "$deb_file"
}
package_rpm() {
local target binary rpm_arch
target="$1"
binary="$2"
if ! rpm_arch="$(rpm_arch_for_target "$target")"; then
echo "warning: no RPM architecture mapping for ${target}; skipping .rpm package" >&2
return 0
fi
if ! command -v cargo-generate-rpm >/dev/null 2>&1; then
echo "warning: cargo-generate-rpm is not available; skipping .rpm package" >&2
return 0
fi
mkdir -p target/release
cp "$binary" target/release/burrow
if cargo generate-rpm -p burrow; then
find target/generate-rpm -type f -name '*.rpm' -exec cp {} "${out_root}/linux/" \; 2>/dev/null || true
find "${out_root}/linux" -maxdepth 1 -type f -name '*.rpm' -print | while read -r rpm; do
sha256_file "$rpm"
done
else
echo "warning: cargo-generate-rpm failed for ${rpm_arch}; skipping .rpm package" >&2
fi
}
package_pacman() {
local target binary pacman_arch pkg_version pkgrel work source_sum service_sum socket_sum readme_sum license_sum
target="$1"
binary="$2"
if ! pacman_arch="$(pacman_arch_for_target "$target")"; then
echo "warning: no pacman architecture mapping for ${target}; skipping Arch package" >&2
return 0
fi
if ! command -v makepkg >/dev/null 2>&1; then
echo "warning: makepkg is not available; skipping Arch package" >&2
return 0
fi
pkg_version="${BURROW_ARCH_PKGVER:-$(burrow_crate_version)_$(arch_package_suffix)}"
pkgrel="${BURROW_ARCH_PKGREL:-1}"
work="${out_root}/linux/pkg-arch/${pacman_arch}"
rm -rf "$work"
mkdir -p "$work"
install -m 0755 "$binary" "$work/burrow"
install -m 0644 systemd/burrow.service "$work/burrow.service"
install -m 0644 systemd/burrow.socket "$work/burrow.socket"
install -m 0644 README.md "$work/README.md"
install -m 0644 LICENSE.md "$work/LICENSE.md"
source_sum="$(sha256sum "$work/burrow" | awk '{ print $1 }')"
service_sum="$(sha256sum "$work/burrow.service" | awk '{ print $1 }')"
socket_sum="$(sha256sum "$work/burrow.socket" | awk '{ print $1 }')"
readme_sum="$(sha256sum "$work/README.md" | awk '{ print $1 }')"
license_sum="$(sha256sum "$work/LICENSE.md" | awk '{ print $1 }')"
cat > "$work/PKGBUILD" <<EOF
# Maintainer: Burrow Release Automation <packages@burrow.net>
pkgname=burrow
pkgver=${pkg_version}
pkgrel=${pkgrel}
pkgdesc="Burrow VPN daemon and CLI"
arch=("${pacman_arch}")
url="https://git.burrow.net/hackclub/burrow"
license=("GPL-3.0-or-later")
depends=("systemd")
source=("burrow" "burrow.service" "burrow.socket" "README.md" "LICENSE.md")
sha256sums=("${source_sum}" "${service_sum}" "${socket_sum}" "${readme_sum}" "${license_sum}")
package() {
install -Dm755 "\${srcdir}/burrow" "\${pkgdir}/usr/bin/burrow"
install -Dm644 "\${srcdir}/burrow.service" "\${pkgdir}/usr/lib/systemd/system/burrow.service"
install -Dm644 "\${srcdir}/burrow.socket" "\${pkgdir}/usr/lib/systemd/system/burrow.socket"
install -Dm644 "\${srcdir}/README.md" "\${pkgdir}/usr/share/doc/burrow/README.md"
install -Dm644 "\${srcdir}/LICENSE.md" "\${pkgdir}/usr/share/licenses/burrow/LICENSE.md"
}
EOF
(
cd "$work"
makepkg --force --nodeps --noconfirm
)
find "$work" -maxdepth 1 -type f -name '*.pkg.tar.*' -exec cp {} "${out_root}/linux/" \;
find "${out_root}/linux" -maxdepth 1 -type f -name '*.pkg.tar.*' -print | while read -r package; do
sha256_file "$package"
done
}
package_linux() {
local target host archive staging
host="$(rustc -vV | awk '/^host:/ { print $2 }')"
target="${BURROW_LINUX_TARGET:-$host}"
if [[ "$target" != *-linux-* && -z "${BURROW_LINUX_TARGET:-}" ]]; then
mkdir -p "${out_root}/linux"
echo "warning: host target ${target} is not Linux; skipping Linux artifact on this host" >&2
printf 'Linux build was skipped on host target %s. Run this lane on a Linux Namespace runner or set BURROW_LINUX_TARGET explicitly.\n' "$target" > "${out_root}/linux/README.txt"
return 0
fi
staging="${out_root}/linux/burrow-${build_number}-${target}"
mkdir -p "$staging"
cargo build --locked --release -p burrow --bin burrow --target "$target"
install -m 0755 "target/${target}/release/burrow" "$staging/burrow"
copy_license_readme "$staging"
archive="${out_root}/linux/burrow-${build_number}-${target}.tar.gz"
tar -C "${out_root}/linux" -czf "$archive" "$(basename "$staging")"
sha256_file "$archive"
package_deb "$target" "target/${target}/release/burrow"
package_rpm "$target" "target/${target}/release/burrow"
package_pacman "$target" "target/${target}/release/burrow"
}
package_windows_stub_unix() {
local target exe archive staging
target="${BURROW_WINDOWS_TARGET:-x86_64-pc-windows-gnu}"
staging="${out_root}/windows/burrow-${build_number}-${target}"
mkdir -p "$staging"
target_libdir="$(rustc --print target-libdir --target "$target" 2>/dev/null || true)"
if [[ -z "$target_libdir" || ! -d "$target_libdir" ]]; then
echo "warning: Rust target ${target} is not installed on this host" >&2
printf 'Windows stub build for %s was skipped on this host. Use the Namespace Windows lane for the native artifact.\n' "$target" > "${out_root}/windows/README.txt"
return 0
fi
if cargo build --locked --release -p burrow-windows-stub --target "$target"; then
exe="target/${target}/release/burrow.exe"
else
echo "warning: unable to build the Windows release stub on this host" >&2
printf 'Windows stub build for %s was skipped on this host. Use the Namespace Windows lane for the native artifact.\n' "$target" > "${out_root}/windows/README.txt"
return 0
fi
install -m 0755 "$exe" "$staging/burrow.exe"
copy_license_readme "$staging"
printf 'Burrow Windows stub build %s\n' "$build_number" > "$staging/README-Windows.txt"
archive="${out_root}/windows/burrow-${build_number}-${target}.zip"
(cd "${out_root}/windows" && zip -qr "$archive" "$(basename "$staging")")
sha256_file "$archive"
}
package_sparkle_unsigned() {
local macos_dir appcast channel dmg
channel="${SPARKLE_CHANNEL:-build-${build_number}}"
macos_dir="${out_root}/macos"
appcast="${out_root}/sparkle/${channel}/appcast.xml"
mkdir -p "$(dirname "$appcast")"
dmg="$(find "$macos_dir" -maxdepth 1 -type f -name '*.dmg' -print 2>/dev/null | sort | tail -n 1 || true)"
if [[ -z "$dmg" ]]; then
echo "No macOS DMG exists yet; Sparkle appcast generation is waiting on the macOS signed artifact." > "${out_root}/sparkle/${channel}/README.txt"
return 0
fi
local name length pubdate url
name="$(basename "$dmg")"
length="$(wc -c < "$dmg" | tr -d ' ')"
pubdate="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
url="${SPARKLE_DOWNLOAD_PREFIX:-https://releases.burrow.net/sparkle}/${channel}/${name}"
cat > "$appcast" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Burrow ${channel}</title>
<item>
<title>Burrow ${build_number}</title>
<pubDate>${pubdate}</pubDate>
<sparkle:version>${build_number}</sparkle:version>
<sparkle:shortVersionString>0.1</sparkle:shortVersionString>
<enclosure url="${url}" sparkle:version="${build_number}" length="${length}" type="application/octet-stream" />
</item>
</channel>
</rss>
EOF
cp "$dmg" "${out_root}/sparkle/${channel}/"
}
case "$platform" in
all)
package_linux
package_windows_stub_unix
if [[ "$(uname -s)" == "Darwin" ]] && command -v xcodebuild >/dev/null 2>&1; then
Scripts/ci/package-apple-artifacts.sh all
else
mkdir -p "${out_root}/apple"
printf 'Apple artifacts require a macOS runner with Xcode. The release workflow builds them in the dedicated Apple lane.\n' > "${out_root}/apple/README.txt"
fi
package_sparkle_unsigned
;;
linux)
package_linux
;;
windows-stub|windows)
package_windows_stub_unix
;;
sparkle)
package_sparkle_unsigned
;;
apple)
Scripts/ci/package-apple-artifacts.sh all
;;
ios|macos)
Scripts/ci/package-apple-artifacts.sh "$platform"
;;
*)
echo "unknown release platform: $platform" >&2
exit 64
;;
esac
find "$out_root" -type f -print | sort

View file

@ -7,7 +7,8 @@ set -euo pipefail
: "${TOKEN:?TOKEN is required}" : "${TOKEN:?TOKEN is required}"
release_api="${API_URL}/repos/${REPOSITORY}/releases" release_api="${API_URL}/repos/${REPOSITORY}/releases"
tag_api="${release_api}/tags/${RELEASE_TAG}" encoded_release_tag="$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "${RELEASE_TAG}")"
tag_api="${release_api}/tags/${encoded_release_tag}"
release_json="$(mktemp)" release_json="$(mktemp)"
create_json="$(mktemp)" create_json="$(mktemp)"
trap 'rm -f "${release_json}" "${create_json}"' EXIT trap 'rm -f "${release_json}" "${create_json}"' EXIT
@ -50,6 +51,9 @@ if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then
fi fi
for file in dist/*; do for file in dist/*; do
if [[ ! -f "${file}" ]]; then
continue
fi
name="$(basename "${file}")" name="$(basename "${file}")"
asset_id="$(jq -r --arg name "${name}" '.assets[]? | select(.name == $name) | .id' "${release_json}" | head -n1)" asset_id="$(jq -r --arg name "${name}" '.assets[]? | select(.name == $name) | .id' "${release_json}" | head -n1)"
if [[ -n "${asset_id}" ]]; then if [[ -n "${asset_id}" ]]; then

49
Scripts/ci/publish-nix-cache.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
cache_server_name="${BURROW_NIX_CACHE_SERVER_NAME:-burrow}"
cache_server_url="${BURROW_NIX_CACHE_SERVER_URL:-https://nix.burrow.net}"
cache_name="${BURROW_NIX_CACHE_NAME:-burrow}"
cache_ref="${cache_server_name}:${cache_name}"
token="${BURROW_NIX_CACHE_PUSH_TOKEN:-}"
if [[ -z "$token" ]]; then
if [[ "${BURROW_NIX_CACHE_REQUIRED:-false}" == "true" ]]; then
echo "BURROW_NIX_CACHE_PUSH_TOKEN is required to publish Nix cache paths" >&2
exit 1
fi
echo "::notice ::BURROW_NIX_CACHE_PUSH_TOKEN is not set; skipping Nix cache publish."
exit 0
fi
if ! command -v attic >/dev/null 2>&1; then
echo "attic is required to publish Nix cache paths" >&2
exit 127
fi
if ! command -v nix >/dev/null 2>&1; then
echo "nix is required to build paths for cache publishing" >&2
exit 127
fi
attic login "$cache_server_name" "$cache_server_url" "$token" --set-default
push_inputs=("$@")
if [[ "${#push_inputs[@]}" -eq 0 ]]; then
read -r -a attrs <<< "${BURROW_NIX_CACHE_ATTRS:-.#burrow .#forgejo-nsc-dispatcher .#forgejo-nsc-autoscaler}"
if [[ "${#attrs[@]}" -eq 0 ]]; then
echo "no Nix attrs were supplied for cache publishing" >&2
exit 1
fi
mapfile -t push_inputs < <(nix build --no-link --print-out-paths "${attrs[@]}")
fi
if [[ "${#push_inputs[@]}" -eq 0 ]]; then
echo "no Nix output paths were produced for cache publishing" >&2
exit 1
fi
attic push "$cache_ref" "${push_inputs[@]}"

56
Scripts/ci/resolve-nix-bin.sh Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
find_nix_bin() {
local candidate
for candidate in \
"${NIX_BIN:-}" \
"$(command -v nix 2>/dev/null || true)" \
"${HOME:-}/.nix-profile/bin/nix" \
"${HOME:-}/.local/state/nix/profile/bin/nix" \
"/nix/var/nix/profiles/per-user/${USER:-runner}/profile/bin/nix" \
/nix/var/nix/profiles/default/bin/nix \
/run/current-system/sw/bin/nix \
/etc/profiles/per-user/runner/bin/nix \
/Users/runner/.nix-profile/bin/nix \
/Users/runner/.local/state/nix/profile/bin/nix \
/home/runner/.nix-profile/bin/nix \
/home/runner/.local/state/nix/profile/bin/nix \
/nix/profile/bin/nix; do
if [[ -n "$candidate" && -x "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done
candidate="$(
find \
"${HOME:-/nonexistent}" \
/Users/runner \
/home/runner \
/nix/var/nix/profiles \
/etc/profiles/per-user \
-path '*/bin/nix' -type f 2>/dev/null | head -n 1 || true
)"
if [[ -n "$candidate" && -x "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
return 1
}
if nix_bin="$(find_nix_bin)"; then
printf '%s\n' "$nix_bin"
exit 0
fi
if bash Scripts/ci/ensure-nix.sh >/dev/null 2>&1; then
if nix_bin="$(find_nix_bin)"; then
printf '%s\n' "$nix_bin"
exit 0
fi
fi
echo "::error ::nix is required but is not on PATH and no standard install path exists" >&2
exit 1

View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$repo_root"
platform="${1:-all}"
out_dir="${BURROW_APPLE_PROFILES_OUT_DIR:-.forgejo/actions/export}"
app_id="${BURROW_APPLE_BUNDLE_ID:-com.hackclub.burrow}"
network_id="${BURROW_APPLE_NETWORK_EXTENSION_BUNDLE_ID:-${app_id}.network}"
replace_certificate_mismatch="${BURROW_APPLE_REPLACE_CERTIFICATE_MISMATCH:-false}"
if [[ -n "${BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID:-}" || -n "${BURROW_DEVELOPER_ID_CERTIFICATE_ID:-}" ]]; then
replace_certificate_mismatch="${BURROW_APPLE_REPLACE_CERTIFICATE_MISMATCH:-true}"
fi
key_id="${ASC_API_KEY_ID:-${APPSTORE_KEY_ID:-}}"
issuer_id="${ASC_API_ISSUER_ID:-${APPSTORE_KEY_ISSUER_ID:-}}"
key_path="${ASC_API_KEY_PATH:-${APPSTORE_KEY_PATH:-}}"
key_base64="${ASC_API_KEY_BASE64:-}"
if [[ -z "$key_id" || -z "$issuer_id" ]]; then
echo "::notice ::App Store Connect credentials are not available; provisioning profile sync skipped."
exit 0
fi
temp_key=""
cleanup() {
[[ -z "$temp_key" ]] || rm -f "$temp_key"
}
trap cleanup EXIT
if [[ -z "$key_path" ]]; then
if [[ -z "$key_base64" ]]; then
echo "::notice ::ASC_API_KEY_PATH/ASC_API_KEY_BASE64 is not available; provisioning profile sync skipped."
exit 0
fi
temp_key="$(mktemp "${RUNNER_TEMP:-/tmp}/burrow-asc-key.XXXXXX.p8")"
printf '%s' "$key_base64" | base64 --decode > "$temp_key"
chmod 600 "$temp_key"
key_path="$temp_key"
fi
if [[ ! -f "$key_path" ]]; then
echo "::error ::App Store Connect key path does not exist: ${key_path}" >&2
exit 1
fi
mkdir -p "$out_dir"
profile_args=(
--platform "$platform"
--key-id "$key_id"
--issuer-id "$issuer_id"
--key-file "$key_path"
--out-dir "$out_dir"
--app-id "$app_id"
--network-id "$network_id"
--create-missing-bundle-ids "${BURROW_APPLE_CREATE_MISSING_BUNDLE_IDS:-false}"
--enable-capabilities "${BURROW_APPLE_ENABLE_CAPABILITIES:-false}"
--replace-certificate-mismatch "$replace_certificate_mismatch"
--bundle-platform "${BURROW_APPLE_BUNDLE_PLATFORM:-UNIVERSAL}"
--associated-domains "${BURROW_APPLE_ASSOCIATED_DOMAINS:-applinks:burrow.rs?mode=developer,webcredentials:burrow.rs?mode=developer}"
)
if [[ -n "${BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID:-}" ]]; then
profile_args+=(--ios-certificate-id "$BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID")
fi
if [[ -n "${BURROW_DEVELOPER_ID_CERTIFICATE_ID:-}" ]]; then
profile_args+=(--macos-certificate-id "$BURROW_DEVELOPER_ID_CERTIFICATE_ID")
fi
node Scripts/apple/sync-provisioning-profiles.mjs "${profile_args[@]}"

View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}"
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage upload}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage upload}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
source_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}"
if [[ ! -d "$source_dir" ]]; then
echo "package repository directory not found: $source_dir" >&2
exit 1
fi
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI is required for Garage package repository upload" >&2
exit 127
fi
bucket="${BURROW_PACKAGE_GARAGE_BUCKET:-burrow-packages}"
prefix="${BURROW_PACKAGE_GARAGE_PREFIX:-${BURROW_PACKAGE_GCS_PREFIX:-}}"
region="${BURROW_GARAGE_REGION:-garage}"
destination="s3://${bucket}"
if [[ -n "$prefix" ]]; then
destination="${destination}/${prefix}"
fi
export AWS_DEFAULT_REGION="$region"
export AWS_EC2_METADATA_DISABLED=true
aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "$source_dir" "$destination" --no-progress

View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
prefix="${BURROW_PACKAGE_GCS_PREFIX:-}"
source_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}"
if [[ ! -d "$source_dir" ]]; then
echo "package repository directory not found: $source_dir" >&2
exit 1
fi
bucket="${BURROW_PACKAGE_GCS_BUCKET:-burrow-net-packages}"
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS package repository upload" >&2
exit 127
fi
gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q . || {
echo "gcloud has no active account; run Scripts/ci/google-wif-auth.sh first" >&2
exit 1
}
destination="gs://${bucket}"
if [[ -n "$prefix" ]]; then
destination="${destination}/${prefix}"
fi
gcloud storage rsync --recursive "$source_dir" "$destination"

View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
uploaded=0
garage_configured=0
if [[ -n "${BURROW_GARAGE_ENDPOINT:-}" && -n "${AWS_ACCESS_KEY_ID:-}" && -n "${AWS_SECRET_ACCESS_KEY:-}" ]]; then
garage_configured=1
fi
if [[ "$garage_configured" == "1" ]]; then
Scripts/ci/upload-package-garage.sh
uploaded=1
elif [[ "${BURROW_PACKAGE_REQUIRE_GARAGE:-false}" == "true" ]]; then
echo "Garage package repository upload is required, but BURROW_GARAGE_ENDPOINT or AWS credentials are missing" >&2
exit 1
else
echo "::notice ::Garage package repository upload not configured; skipping primary S3-compatible target."
fi
if [[ "${BURROW_PACKAGE_GCS_BACKUP:-true}" != "false" ]]; then
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS package repository backup" >&2
exit 127
fi
if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then
Scripts/ci/google-wif-auth.sh
fi
Scripts/ci/upload-package-repositories.sh
uploaded=1
fi
if [[ "$uploaded" != "1" ]]; then
echo "no package repository storage targets were enabled" >&2
exit 1
fi

View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
: "${BURROW_GARAGE_ENDPOINT:?BURROW_GARAGE_ENDPOINT is required}"
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID is required for Garage upload}"
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY is required for Garage upload}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
source_dir="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${BUILD_NUMBER}}"
if [[ ! -d "$source_dir" ]]; then
echo "release artifact directory not found: $source_dir" >&2
exit 1
fi
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI is required for Garage release upload" >&2
exit 127
fi
bucket="${BURROW_RELEASE_GARAGE_BUCKET:-burrow-releases}"
region="${BURROW_GARAGE_REGION:-garage}"
destination="s3://${bucket}/builds/${BUILD_NUMBER}"
export AWS_DEFAULT_REGION="$region"
export AWS_EC2_METADATA_DISABLED=true
aws --endpoint-url "$BURROW_GARAGE_ENDPOINT" s3 sync "$source_dir" "$destination" --no-progress

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
source_dir="${BURROW_RELEASE_OUT:-${repo_root}/dist/builds/${BUILD_NUMBER}}"
if [[ ! -d "$source_dir" ]]; then
echo "release artifact directory not found: $source_dir" >&2
exit 1
fi
bucket="${BURROW_RELEASE_GCS_BUCKET:-burrow-net-releases}"
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS release upload" >&2
exit 127
fi
gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q . || {
echo "gcloud has no active account; run Scripts/ci/google-wif-auth.sh first" >&2
exit 1
}
gcloud storage rsync --recursive "$source_dir" "gs://${bucket}/builds/${BUILD_NUMBER}"

View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "${repo_root}"
uploaded=0
garage_configured=0
if [[ -n "${BURROW_GARAGE_ENDPOINT:-}" && -n "${AWS_ACCESS_KEY_ID:-}" && -n "${AWS_SECRET_ACCESS_KEY:-}" ]]; then
garage_configured=1
fi
if [[ "$garage_configured" == "1" ]]; then
Scripts/ci/upload-release-garage.sh
uploaded=1
elif [[ "${BURROW_RELEASE_REQUIRE_GARAGE:-false}" == "true" ]]; then
echo "Garage release upload is required, but BURROW_GARAGE_ENDPOINT or AWS credentials are missing" >&2
exit 1
else
echo "::notice ::Garage release upload not configured; skipping primary S3-compatible target."
fi
if [[ "${BURROW_RELEASE_GCS_BACKUP:-true}" != "false" ]]; then
if ! command -v gcloud >/dev/null 2>&1; then
echo "gcloud is required for GCS release backup" >&2
exit 127
fi
if ! gcloud auth list --filter=status:ACTIVE --format='value(account)' | grep -q .; then
Scripts/ci/google-wif-auth.sh
fi
Scripts/ci/upload-release-gcs.sh
uploaded=1
fi
if [[ "$uploaded" != "1" ]]; then
echo "no release storage targets were enabled" >&2
exit 1
fi

View file

@ -0,0 +1,205 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: Scripts/forgejo-dispatch-via-host.sh <workflow> [--ref <ref>] [--inputs <json>] [--inputs-file <path>] [--return-run-info]
Dispatch a Burrow Forgejo workflow by SSHing to the live forge host and using
the managed dispatcher token from the host runtime.
Environment:
FORGEJO_BASE_URL Default: https://git.burrow.net
FORGEJO_REPO Default: hackclub/burrow
BURROW_FORGE_HOST Default: root@git.burrow.net
BURROW_FORGE_HOST_ADDRESS Optional SSH HostName override
BURROW_FORGE_SSH_KEY Optional explicit SSH private key
AGE_FORGE_SSH_KEY Optional injected SSH private key material
BURROW_FORGE_DISPATCHER_CONFIG_FILE Default: /run/agenix/burrowForgejoNscDispatcherConfig
EOF
}
base_url="${FORGEJO_BASE_URL:-https://git.burrow.net}"
repo_slug="${FORGEJO_REPO:-hackclub/burrow}"
forge_host="${BURROW_FORGE_HOST:-root@git.burrow.net}"
forge_host_address="${BURROW_FORGE_HOST_ADDRESS:-}"
dispatcher_config_file="${BURROW_FORGE_DISPATCHER_CONFIG_FILE:-/run/agenix/burrowForgejoNscDispatcherConfig}"
workflow=""
ref="main"
inputs_json=""
return_run_info="false"
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--ref)
ref="${2:-}"
shift 2
;;
--inputs)
inputs_json="${2:-}"
shift 2
;;
--inputs-file)
inputs_json="$(cat "${2:-}")"
shift 2
;;
--return-run-info)
return_run_info="true"
shift
;;
*)
if [[ -z "$workflow" ]]; then
workflow="$1"
shift
else
echo "Unexpected argument: $1" >&2
usage >&2
exit 64
fi
;;
esac
done
if [[ -z "$workflow" ]]; then
echo "Missing workflow name or file, for example build-release.yml." >&2
usage >&2
exit 64
fi
script_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
resolved_forge_ssh_key="$("${script_dir}/resolve-forge-ssh-key.sh")"
eval "$resolved_forge_ssh_key"
ssh_key="${BURROW_FORGE_SSH_KEY}"
if [[ -n "$inputs_json" ]]; then
if ! python3 - "$inputs_json" <<'PY' >/dev/null
import json
import sys
value = json.loads(sys.argv[1])
if not isinstance(value, dict):
raise SystemExit(1)
PY
then
echo "--inputs must be a JSON object." >&2
exit 64
fi
fi
workflow_path="$(python3 - "$workflow" <<'PY'
import sys
import urllib.parse
print(urllib.parse.quote(sys.argv[1], safe=""))
PY
)"
payload="$(python3 - "$ref" "$inputs_json" "$return_run_info" <<'PY'
import json
import sys
ref = sys.argv[1]
inputs_arg = sys.argv[2]
return_run_info = sys.argv[3] == "true"
payload = {"ref": ref}
if inputs_arg:
inputs = json.loads(inputs_arg)
payload["inputs"] = {key: str(value) for key, value in inputs.items()}
if return_run_info:
payload["return_run_info"] = True
print(json.dumps(payload))
PY
)"
payload_b64="$(python3 - "$payload" <<'PY'
import base64
import sys
print(base64.b64encode(sys.argv[1].encode()).decode())
PY
)"
ssh_opts=(
-i "$ssh_key"
-o IdentitiesOnly=yes
-o UserKnownHostsFile=/dev/null
-o GlobalKnownHostsFile=/dev/null
-o StrictHostKeyChecking=accept-new
)
if [[ -n "$forge_host_address" ]]; then
ssh_opts+=(-o "HostName=$forge_host_address")
fi
response="$(
ssh "${ssh_opts[@]}" "$forge_host" bash -s -- \
"$base_url" "$repo_slug" "$workflow_path" "$dispatcher_config_file" "$payload_b64" <<'EOF'
set -euo pipefail
base_url="$1"
repo_slug="$2"
workflow_path="$3"
dispatcher_config_file="$4"
payload_b64="$5"
payload="$(python3 - "$payload_b64" <<'PY'
import base64
import sys
print(base64.b64decode(sys.argv[1]).decode())
PY
)"
if [[ ! -s "$dispatcher_config_file" ]]; then
echo "Forge dispatcher config is missing or empty: $dispatcher_config_file" >&2
exit 1
fi
runtime_token="$(
awk '
/^[A-Za-z0-9_-]+:/ { in_forgejo = ($0 ~ /^forgejo:/); next }
in_forgejo && /^[[:space:]]+token:/ {
sub(/^[[:space:]]+token:[[:space:]]*/, "")
gsub(/^"/, "")
gsub(/"$/, "")
gsub(/^\047/, "")
gsub(/\047$/, "")
print
exit
}
' "$dispatcher_config_file" | tr -d "\r\n"
)"
if [[ -z "$runtime_token" ]]; then
echo "Forge dispatcher token not found in $dispatcher_config_file" >&2
exit 1
fi
url="${base_url%/}/api/v1/repos/${repo_slug}/actions/workflows/${workflow_path}/dispatches"
response_file="$(mktemp)"
http_code="$(
curl -sS -o "$response_file" -w '%{http_code}' -X POST \
-H "Authorization: token ${runtime_token}" \
-H "Content-Type: application/json" \
--data-binary "$payload" \
"$url"
)"
if [[ "$http_code" != 2* ]]; then
cat "$response_file" >&2
rm -f "$response_file"
exit 22
fi
cat "$response_file"
rm -f "$response_file"
EOF
)"
if [[ "$return_run_info" == "true" ]]; then
printf '%s\n' "$response"
else
echo "Dispatched ${workflow} on ${ref} via ${forge_host}."
fi

240
Scripts/forgejo-dispatch.sh Executable file
View file

@ -0,0 +1,240 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: Scripts/forgejo-dispatch.sh <workflow> [--ref <ref>] [--inputs <json>] [--inputs-file <path>] [--return-run-info]
Dispatch a Burrow Forgejo workflow through the Forgejo API.
Environment:
FORGEJO_BASE_URL Default: https://git.burrow.net
FORGEJO_REPO Default: hackclub/burrow
FORGEJO_PAT Token string
FORGEJO_PAT_FILE Path to plaintext token
FORGEJO_DISPATCH_HOST_FALLBACK auto, always, or never. Default: auto
EOF
}
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base_url="${FORGEJO_BASE_URL:-https://git.burrow.net}"
repo_slug="${FORGEJO_REPO:-hackclub/burrow}"
host_fallback="${FORGEJO_DISPATCH_HOST_FALLBACK:-auto}"
original_args=("$@")
workflow=""
ref="main"
inputs_json=""
return_run_info="false"
case "$host_fallback" in
auto|always|never) ;;
*)
echo "FORGEJO_DISPATCH_HOST_FALLBACK must be auto, always, or never." >&2
exit 64
;;
esac
host_dispatch() {
local reason="$1"
local fallback="${repo_root}/Scripts/forgejo-dispatch-via-host.sh"
if [[ ! -x "$fallback" ]]; then
return 1
fi
echo "Forgejo API dispatch ${reason}; retrying through the forge host." >&2
exec "$fallback" "${original_args[@]}"
}
if [[ "$host_fallback" == "always" ]]; then
if ! host_dispatch "forced by FORGEJO_DISPATCH_HOST_FALLBACK=always"; then
echo "Forge host dispatcher is not available." >&2
exit 1
fi
fi
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--ref)
ref="${2:-}"
shift 2
;;
--inputs)
inputs_json="${2:-}"
shift 2
;;
--inputs-file)
inputs_json="$(cat "${2:-}")"
shift 2
;;
--return-run-info)
return_run_info="true"
shift
;;
*)
if [[ -z "$workflow" ]]; then
workflow="$1"
shift
else
echo "Unexpected argument: $1" >&2
usage >&2
exit 64
fi
;;
esac
done
if [[ -z "$workflow" ]]; then
echo "Missing workflow name or file, for example build-release.yml." >&2
usage >&2
exit 64
fi
extract_dispatcher_token() {
python3 - "$1" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
in_forgejo = False
for line in path.read_text(encoding="utf-8").splitlines():
if re.match(r"^[A-Za-z0-9_-]+:", line):
in_forgejo = line.startswith("forgejo:")
continue
if not in_forgejo:
continue
match = re.match(r"\s+token:\s*['\"]?([^'\"]+)['\"]?\s*$", line)
if match:
print(match.group(1))
break
PY
}
decrypt_age_secret() {
local path="$1"
if [[ ! -f "$path" ]]; then
return 1
fi
if command -v nix >/dev/null 2>&1; then
nix run "${repo_root}#agenix" -- -d "$path" 2>/dev/null && return 0
fi
if command -v agenix >/dev/null 2>&1; then
agenix -d "$path" 2>/dev/null && return 0
fi
return 1
}
pat="${FORGEJO_PAT:-}"
pat_source="none"
if [[ -n "$pat" ]]; then
pat_source="FORGEJO_PAT"
fi
if [[ -z "$pat" && -n "${FORGEJO_PAT_FILE:-}" && -f "$FORGEJO_PAT_FILE" ]]; then
pat="$(tr -d '\r\n' < "$FORGEJO_PAT_FILE")"
if [[ -n "$pat" ]]; then
pat_source="FORGEJO_PAT_FILE"
fi
fi
if [[ -z "$pat" ]]; then
for default_file in \
"${repo_root}/intake/forgejo_dispatch_pat.txt" \
"${repo_root}/intake/forgejo_nsc_dispatcher.yaml"
do
if [[ -s "$default_file" ]]; then
if [[ "$default_file" == *.yaml ]]; then
pat="$(extract_dispatcher_token "$default_file" | tr -d '\r\n')"
else
pat="$(tr -d '\r\n' < "$default_file")"
fi
fi
if [[ -n "$pat" ]]; then
pat_source="$default_file"
break
fi
done
fi
if [[ -z "$pat" ]]; then
decrypted_config="$(mktemp)"
trap 'rm -f "$decrypted_config"' EXIT
if decrypt_age_secret "${repo_root}/secrets/infra/forgejo-nsc-dispatcher-config.age" > "$decrypted_config"; then
pat="$(extract_dispatcher_token "$decrypted_config" | tr -d '\r\n')"
if [[ -n "$pat" ]]; then
pat_source="secrets/infra/forgejo-nsc-dispatcher-config.age"
fi
fi
fi
if [[ -z "$pat" ]]; then
if [[ "$host_fallback" == "auto" ]]; then
if host_dispatch "has no local token"; then
exit 0
fi
fi
echo "Forgejo token not found. Set FORGEJO_PAT or FORGEJO_PAT_FILE." >&2
exit 1
fi
if [[ -n "$inputs_json" ]]; then
if ! printf '%s\n' "$inputs_json" | jq -e 'type == "object"' >/dev/null 2>&1; then
echo "--inputs must be a JSON object." >&2
exit 64
fi
inputs_json="$(printf '%s\n' "$inputs_json" | jq -c 'with_entries(.value |= tostring)')"
payload="$(jq -n --arg ref "$ref" --argjson inputs "$inputs_json" --argjson return_run_info "$return_run_info" \
'{ref:$ref, inputs:$inputs} + (if $return_run_info then {return_run_info:true} else {} end)')"
else
payload="$(jq -n --arg ref "$ref" --argjson return_run_info "$return_run_info" \
'{ref:$ref} + (if $return_run_info then {return_run_info:true} else {} end)')"
fi
workflow_path="$(python3 - "$workflow" <<'PY'
import sys
import urllib.parse
print(urllib.parse.quote(sys.argv[1], safe=""))
PY
)"
url="${base_url%/}/api/v1/repos/${repo_slug}/actions/workflows/${workflow_path}/dispatches"
response_file="$(mktemp)"
trap 'rm -f "$response_file" "${decrypted_config:-}"' EXIT
curl_status=0
http_code="$(
curl -sS -o "$response_file" -w '%{http_code}' -X POST \
-H "Authorization: token ${pat}" \
-H "Content-Type: application/json" \
--data-binary "$payload" \
"$url"
)" || curl_status="$?"
if [[ "$curl_status" != "0" || "$http_code" != 2* ]]; then
if [[ "$host_fallback" == "auto" && "$pat_source" != "FORGEJO_PAT" && ( "$http_code" == "401" || "$http_code" == "403" ) ]]; then
if host_dispatch "returned HTTP ${http_code} with ${pat_source}"; then
exit 0
fi
fi
cat "$response_file" >&2 || true
if [[ "$curl_status" != "0" ]]; then
exit "$curl_status"
fi
exit 22
fi
cat "$response_file"
if [[ "$return_run_info" != "true" ]]; then
echo "Dispatched ${workflow} on ${ref}."
fi

41
Scripts/grafana-tofu.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
}
)"
work_dir="${BURROW_GRAFANA_TOFU_DIR:-$ROOT/infra/grafana}"
plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}"
usage() {
cat <<'EOF'
Usage: Scripts/grafana-tofu.sh [tofu args...]
Runs OpenTofu for Burrow Grafana API configuration. This stack creates folders
and dashboards only; Nix owns the Grafana service, authentication, data sources,
and boot-time provisioning files.
Examples:
Scripts/grafana-tofu.sh init -backend-config=backend.hcl
Scripts/grafana-tofu.sh plan -var-file=terraform.tfvars
Local syntax check:
Scripts/grafana-tofu.sh init -backend=false
Scripts/grafana-tofu.sh validate
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$plugin_cache_dir"
export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir"
export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}"
exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@"

41
Scripts/identity-tofu.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
}
)"
work_dir="${BURROW_IDENTITY_TOFU_DIR:-$ROOT/infra/identity}"
plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}"
usage() {
cat <<'EOF'
Usage: Scripts/identity-tofu.sh [tofu args...]
Runs OpenTofu for Burrow Google KMS and Workload Identity Federation resources.
Cloud provider credentials must come from the normal provider environment or a
WIF flow; do not put credentials in backend config.
Examples:
Scripts/identity-tofu.sh init -backend-config=backend.hcl
Scripts/identity-tofu.sh plan -var-file=terraform.tfvars
Local syntax check:
Scripts/identity-tofu.sh init -backend=false
Scripts/identity-tofu.sh validate
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$plugin_cache_dir"
export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir"
export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}"
exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@"

41
Scripts/openbao-tofu.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
}
)"
work_dir="${BURROW_OPENBAO_TOFU_DIR:-$ROOT/infra/openbao}"
plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}"
usage() {
cat <<'EOF'
Usage: Scripts/openbao-tofu.sh [tofu args...]
Runs OpenTofu for Burrow OpenBao cloud seal and OpenBao API configuration. The
Vault/OpenBao provider reads VAULT_TOKEN from the environment when managing
OpenBao mounts, policies, or auth backends.
Examples:
Scripts/openbao-tofu.sh init -backend-config=backend.hcl
Scripts/openbao-tofu.sh plan -var-file=terraform.tfvars
Local syntax check:
Scripts/openbao-tofu.sh init -backend=false
Scripts/openbao-tofu.sh validate
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$plugin_cache_dir"
export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir"
export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}"
exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@"

View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
apply=0
prefer_packagekit=1
usage() {
cat <<'EOF'
Usage: Scripts/package/bootstrap-native-daemon.sh [--apply] [--no-packagekit]
Detects the host package manager and prints, or with --apply runs, the native
install command for the Burrow daemon package. This is the host-side bootstrap
equivalent the Flatpak GUI can point users at when PackageKit is unavailable.
This assumes the Burrow native repository for the detected package manager is
already configured. Repository setup should be explicit because it establishes
package trust roots.
EOF
}
while [[ "$#" -gt 0 ]]; do
case "$1" in
--apply)
apply=1
;;
--no-packagekit)
prefer_packagekit=0
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
exit 64
;;
esac
shift
done
run_or_print() {
if [[ "$apply" == "1" ]]; then
"$@"
else
printf '%q ' "$@"
printf '\n'
fi
}
if [[ "$prefer_packagekit" == "1" ]] && command -v pkcon >/dev/null 2>&1; then
run_or_print pkcon install burrow
exit 0
fi
if command -v apt-get >/dev/null 2>&1; then
run_or_print sudo apt-get install burrow
elif command -v dnf >/dev/null 2>&1; then
run_or_print sudo dnf install burrow
elif command -v zypper >/dev/null 2>&1; then
run_or_print sudo zypper install burrow
elif command -v pacman >/dev/null 2>&1; then
run_or_print sudo pacman -S burrow
elif command -v apk >/dev/null 2>&1; then
run_or_print sudo apk add burrow
elif command -v xbps-install >/dev/null 2>&1; then
run_or_print sudo xbps-install -S burrow
elif command -v eopkg >/dev/null 2>&1; then
run_or_print sudo eopkg install burrow
elif command -v emerge >/dev/null 2>&1; then
run_or_print sudo emerge --ask net-vpn/burrow
elif command -v swupd >/dev/null 2>&1; then
run_or_print sudo swupd bundle-add burrow
elif command -v nix >/dev/null 2>&1; then
run_or_print nix profile install git+https://git.burrow.net/hackclub/burrow
else
echo "No supported native package manager found. Install Burrow through DEB, RPM, pacman, AUR, Flatpak GUI plus daemon package, or the NixOS flake." >&2
exit 69
fi

View file

@ -0,0 +1,248 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
pwd
}
)"
input_dir="${BURROW_PACKAGE_INPUT_DIR:-${repo_root}/dist/packages}"
output_dir="${BURROW_PACKAGE_REPOSITORY_DIR:-${repo_root}/publish/repositories}"
channel="${BURROW_PACKAGE_CHANNEL:-stable}"
suite="${BURROW_APT_SUITE:-stable}"
component="${BURROW_APT_COMPONENT:-main}"
apt_arch="${BURROW_APT_ARCH:-${BURROW_PACKAGE_ARCH:-amd64}}"
rpm_arch="${BURROW_RPM_ARCH:-x86_64}"
pacman_arch="${BURROW_PACMAN_ARCH:-x86_64}"
repo_name="${BURROW_ARCH_REPO_NAME:-burrow}"
generic_kms_public_key_pem="${BURROW_PACKAGE_REPO_KMS_PUBLIC_KEY_PEM:-}"
generic_kms_key="${BURROW_PACKAGE_REPO_KMS_KEY:-}"
apt_kms_public_key_pem="${BURROW_APT_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}"
apt_kms_key="${BURROW_APT_REPO_KMS_KEY:-${generic_kms_key:-package-apt-repository}}"
rpm_kms_public_key_pem="${BURROW_RPM_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}"
rpm_kms_key="${BURROW_RPM_REPO_KMS_KEY:-${generic_kms_key:-package-rpm-repository}}"
pacman_kms_public_key_pem="${BURROW_PACMAN_REPO_KMS_PUBLIC_KEY_PEM:-$generic_kms_public_key_pem}"
pacman_kms_key="${BURROW_PACMAN_REPO_KMS_KEY:-${generic_kms_key:-package-pacman-repository}}"
usage() {
cat <<'EOF'
Usage: Scripts/package/build-repositories.sh [apt|rpm|arch|keys|all]
Builds native Linux package repositories from existing package artifacts.
Inputs:
BURROW_PACKAGE_INPUT_DIR directory containing .deb, .rpm, and .pkg.tar.* files
BURROW_PACKAGE_REPOSITORY_DIR output directory, default publish/repositories
BURROW_APT_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for APT OpenPGP signatures
BURROW_APT_REPO_KMS_KEY Google KMS key name for APT signatures
BURROW_RPM_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for RPM repository signatures
BURROW_RPM_REPO_KMS_KEY Google KMS key name for RPM repository signatures
BURROW_PACMAN_REPO_KMS_PUBLIC_KEY_PEM Google KMS public key PEM for pacman signatures
BURROW_PACMAN_REPO_KMS_KEY Google KMS key name for pacman signatures
BURROW_PACKAGE_REPO_KMS_PUBLIC_KEY_PEM fallback public key PEM for all formats
BURROW_PACKAGE_REPO_KMS_KEY fallback KMS key name for all formats
BURROW_KMS_KEYRING/BURROW_KMS_LOCATION/BURROW_KMS_VERSION/GOOGLE_CLOUD_PROJECT
The repository metadata is signed with Google KMS-backed OpenPGP detached
signatures when each format's KMS public key and key name are configured.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mode="${1:-all}"
sha256_file() {
local file="$1"
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" > "${file}.sha256"
else
sha256sum "$file" > "${file}.sha256"
fi
}
sign_file() {
local file="$1"
local output="${2:-${file}.sig}"
local armor="${3:-0}"
local kms_key="$4"
local kms_public_key_pem="$5"
if [[ -z "$kms_public_key_pem" || -z "$kms_key" ]]; then
echo "warning: KMS OpenPGP signing disabled for ${file}; set the ${file} repository KMS public key and key name" >&2
return 0
fi
args=(
"${repo_root}/Scripts/package/google-kms-openpgp.py"
detach-sign
--public-key-pem "$kms_public_key_pem"
--kms-key "$kms_key"
--input "$file"
--output "$output"
)
if [[ "$armor" == "1" ]]; then
args+=(--armor)
fi
"${args[@]}"
}
write_public_key() {
local name="$1"
local kms_key="$2"
local kms_public_key_pem="$3"
local user_id="$4"
local output="${output_dir}/keys/${name}.asc"
if [[ -z "$kms_public_key_pem" || -z "$kms_key" ]]; then
echo "warning: KMS OpenPGP public key export disabled for ${name}; set the repository KMS public key and key name" >&2
return 0
fi
mkdir -p "$(dirname "$output")"
"${repo_root}/Scripts/package/google-kms-openpgp.py" \
public-key \
--public-key-pem "$kms_public_key_pem" \
--kms-key "$kms_key" \
--user-id "$user_id" \
--output "$output" \
--armor
}
write_release_file() {
local release_path="$1"
local dist_dir="$2"
local now
now="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
{
printf 'Origin: Burrow\n'
printf 'Label: Burrow\n'
printf 'Suite: %s\n' "$suite"
printf 'Codename: %s\n' "$suite"
printf 'Date: %s\n' "$now"
printf 'Architectures: %s\n' "$apt_arch"
printf 'Components: %s\n' "$component"
printf 'Description: Burrow native package repository\n'
printf 'SHA256:\n'
(
cd "$dist_dir"
find . -type f ! -name Release -print | LC_ALL=C sort | while read -r file; do
clean="${file#./}"
sum="$(sha256sum "$clean" | awk '{print $1}')"
size="$(wc -c < "$clean" | tr -d ' ')"
printf ' %s %s %s\n' "$sum" "$size" "$clean"
done
)
} > "$release_path"
}
build_apt() {
local root binary_dir pool_dir release_dir release_file
root="${output_dir}/apt"
pool_dir="${root}/pool/${component}/b/burrow"
binary_dir="${root}/dists/${suite}/${component}/binary-${apt_arch}"
release_dir="${root}/dists/${suite}"
mkdir -p "$pool_dir" "$binary_dir" "$release_dir"
find "$input_dir" -type f -name '*.deb' -exec cp {} "$pool_dir/" \; 2>/dev/null || true
if ! find "$pool_dir" -type f -name '*.deb' -print -quit | grep -q .; then
echo "warning: no .deb packages found under ${input_dir}; apt repository contains README only" >&2
printf 'Place Burrow .deb artifacts in this repository pool before publishing.\n' > "${root}/README.txt"
return 0
fi
if ! command -v dpkg-scanpackages >/dev/null 2>&1; then
echo "error: dpkg-scanpackages is required to build the apt repository" >&2
exit 127
fi
(
cd "$root"
dpkg-scanpackages --arch "$apt_arch" "pool/${component}" /dev/null > "dists/${suite}/${component}/binary-${apt_arch}/Packages"
gzip -9cn "dists/${suite}/${component}/binary-${apt_arch}/Packages" > "dists/${suite}/${component}/binary-${apt_arch}/Packages.gz"
)
release_file="${release_dir}/Release"
write_release_file "$release_file" "$release_dir"
sign_file "$release_file" "${release_file}.gpg" 1 "$apt_kms_key" "$apt_kms_public_key_pem"
sha256_file "$release_file"
}
build_rpm() {
local root repomd
root="${output_dir}/rpm/${channel}/${rpm_arch}"
mkdir -p "$root"
find "$input_dir" -type f -name '*.rpm' -exec cp {} "$root/" \; 2>/dev/null || true
if ! find "$root" -maxdepth 1 -type f -name '*.rpm' -print -quit | grep -q .; then
echo "warning: no .rpm packages found under ${input_dir}; rpm repository contains README only" >&2
printf 'Place Burrow .rpm artifacts in this directory before publishing.\n' > "${root}/README.txt"
return 0
fi
if ! command -v createrepo_c >/dev/null 2>&1; then
echo "error: createrepo_c is required to build the rpm repository" >&2
exit 127
fi
createrepo_c "$root"
repomd="${root}/repodata/repomd.xml"
sign_file "$repomd" "${repomd}.asc" 1 "$rpm_kms_key" "$rpm_kms_public_key_pem"
sha256_file "$repomd"
}
build_arch() {
local root db
root="${output_dir}/arch/${repo_name}/${pacman_arch}"
mkdir -p "$root"
find "$input_dir" -type f \( -name '*.pkg.tar.zst' -o -name '*.pkg.tar.xz' -o -name '*.pkg.tar.gz' \) -exec cp {} "$root/" \; 2>/dev/null || true
if ! find "$root" -maxdepth 1 -type f -name '*.pkg.tar.*' -print -quit | grep -q .; then
echo "warning: no Arch packages found under ${input_dir}; pacman repository contains README only" >&2
printf 'Place Burrow .pkg.tar.* artifacts in this directory before publishing.\n' > "${root}/README.txt"
return 0
fi
if ! command -v repo-add >/dev/null 2>&1; then
echo "error: repo-add is required to build the Arch repository" >&2
exit 127
fi
(
cd "$root"
repo-add "${repo_name}.db.tar.gz" ./*.pkg.tar.*
)
db="${root}/${repo_name}.db.tar.gz"
sign_file "$db" "${db}.sig" 0 "$pacman_kms_key" "$pacman_kms_public_key_pem"
find "$root" -maxdepth 1 -type f -name '*.pkg.tar.*' ! -name '*.sig' -print | while read -r package; do
sign_file "$package" "${package}.sig" 0 "$pacman_kms_key" "$pacman_kms_public_key_pem"
done
sha256_file "$db"
}
build_keys() {
write_public_key "burrow-package-apt-repository" "$apt_kms_key" "$apt_kms_public_key_pem" "Burrow APT Package Repository <packages@burrow.net>"
write_public_key "burrow-package-rpm-repository" "$rpm_kms_key" "$rpm_kms_public_key_pem" "Burrow RPM Package Repository <packages@burrow.net>"
write_public_key "burrow-package-pacman-repository" "$pacman_kms_key" "$pacman_kms_public_key_pem" "Burrow pacman Package Repository <packages@burrow.net>"
}
mkdir -p "$output_dir"
case "$mode" in
apt)
build_apt
;;
rpm)
build_rpm
;;
arch)
build_arch
;;
keys)
build_keys
;;
all)
build_keys
build_apt
build_rpm
build_arch
;;
*)
echo "unknown repository mode: ${mode}" >&2
exit 64
;;
esac

View file

@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""Create OpenPGP public keys and detached signatures backed by Google KMS.
This intentionally implements only the narrow RSA/SHA-256 subset Burrow needs
for Linux package repository metadata. The private key lives in Google Cloud KMS;
this script builds OpenPGP packets locally and asks KMS to produce the RSA
signature value.
"""
from __future__ import annotations
import argparse
import base64
import binascii
import hashlib
import os
import struct
import subprocess
import sys
import tempfile
import time
from pathlib import Path
RSA_PUBLIC_KEY_ALGO = 1
SHA256_HASH_ALGO = 8
def parse_der_length(data: bytes, offset: int) -> tuple[int, int]:
first = data[offset]
offset += 1
if first < 0x80:
return first, offset
count = first & 0x7F
if count == 0 or count > 4:
raise ValueError("unsupported DER length")
value = int.from_bytes(data[offset : offset + count], "big")
return value, offset + count
def parse_der_tlv(data: bytes, offset: int, expected_tag: int | None = None) -> tuple[int, bytes, int]:
tag = data[offset]
offset += 1
length, offset = parse_der_length(data, offset)
value = data[offset : offset + length]
if len(value) != length:
raise ValueError("truncated DER value")
if expected_tag is not None and tag != expected_tag:
raise ValueError(f"expected DER tag {expected_tag:#x}, got {tag:#x}")
return tag, value, offset + length
def pem_to_der(pem: bytes) -> bytes:
lines = [
line.strip()
for line in pem.splitlines()
if line and not line.startswith(b"-----")
]
return base64.b64decode(b"".join(lines))
def parse_rsa_public_key_from_pem(path: Path) -> tuple[int, int]:
der = pem_to_der(path.read_bytes())
_, spki, end = parse_der_tlv(der, 0, 0x30)
if end != len(der):
raise ValueError("trailing data after SubjectPublicKeyInfo")
_, _alg, offset = parse_der_tlv(spki, 0, 0x30)
_, bit_string, offset = parse_der_tlv(spki, offset, 0x03)
if offset != len(spki):
raise ValueError("trailing data after SPKI bit string")
if not bit_string or bit_string[0] != 0:
raise ValueError("unsupported SPKI bit string")
rsa_der = bit_string[1:]
_, rsa_seq, end = parse_der_tlv(rsa_der, 0, 0x30)
if end != len(rsa_der):
raise ValueError("trailing data after RSA public key")
_, n_bytes, offset = parse_der_tlv(rsa_seq, 0, 0x02)
_, e_bytes, offset = parse_der_tlv(rsa_seq, offset, 0x02)
if offset != len(rsa_seq):
raise ValueError("trailing data after RSA integers")
return int.from_bytes(n_bytes, "big"), int.from_bytes(e_bytes, "big")
def packet_header(tag: int, length: int) -> bytes:
if length < 192:
return bytes([0xC0 | tag, length])
if length < 8384:
length -= 192
return bytes([0xC0 | tag, (length >> 8) + 192, length & 0xFF])
return bytes([0xC0 | tag, 0xFF]) + struct.pack(">I", length)
def old_packet_header(tag: int, length: int) -> bytes:
if length > 0xFFFF:
raise ValueError("old-format two-octet packet length exceeded")
return bytes([(tag << 2) | 1]) + struct.pack(">H", length)
def mpi(value: int) -> bytes:
if value < 0:
raise ValueError("MPI value must be non-negative")
raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big")
return struct.pack(">H", value.bit_length()) + raw.lstrip(b"\x00")
def subpacket(kind: int, body: bytes) -> bytes:
payload = bytes([kind]) + body
length = len(payload)
if length < 192:
return bytes([length]) + payload
if length < 8384:
length -= 192
return bytes([(length >> 8) + 192, length & 0xFF]) + payload
return b"\xff" + struct.pack(">I", length) + payload
def public_key_body(n: int, e: int, created_at: int) -> bytes:
return (
b"\x04"
+ struct.pack(">I", created_at)
+ bytes([RSA_PUBLIC_KEY_ALGO])
+ mpi(n)
+ mpi(e)
)
def fingerprint(public_body: bytes) -> bytes:
return hashlib.sha1(old_packet_header(6, len(public_body)) + public_body).digest()
def armor(kind: str, body: bytes) -> str:
b64 = base64.b64encode(body).decode("ascii")
crc = crc24(body)
checksum = base64.b64encode(crc.to_bytes(3, "big")).decode("ascii")
lines = [f"-----BEGIN PGP {kind}-----", ""]
lines.extend(b64[i : i + 64] for i in range(0, len(b64), 64))
lines.append(f"={checksum}")
lines.append(f"-----END PGP {kind}-----")
return "\n".join(lines) + "\n"
def crc24(data: bytes) -> int:
crc = 0xB704CE
for octet in data:
crc ^= octet << 16
for _ in range(8):
crc <<= 1
if crc & 0x1000000:
crc ^= 0x1864CFB
return crc & 0xFFFFFF
def kms_sign(payload: bytes, args: argparse.Namespace) -> bytes:
with tempfile.TemporaryDirectory(prefix="burrow-kms-openpgp.") as tmp:
input_path = Path(tmp) / "payload"
signature_path = Path(tmp) / "signature.b64"
input_path.write_bytes(payload)
command = [
"gcloud",
"kms",
"asymmetric-sign",
"--location",
args.kms_location,
"--keyring",
args.kms_keyring,
"--key",
args.kms_key,
"--version",
args.kms_version,
"--digest-algorithm",
"sha256",
"--input-file",
str(input_path),
"--signature-file",
str(signature_path),
]
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
subprocess.run(command, check=True)
return base64.b64decode(signature_path.read_text(encoding="utf-8").strip())
def signature_packet(
signed_payload: bytes,
public_body: bytes,
sig_type: int,
created_at: int,
args: argparse.Namespace,
) -> bytes:
fpr = fingerprint(public_body)
key_id = fpr[-8:]
hashed = b"".join(
[
subpacket(2, struct.pack(">I", created_at)),
subpacket(33, b"\x04" + fpr),
]
)
unhashed = subpacket(16, key_id)
sig_data = (
b"\x04"
+ bytes([sig_type, RSA_PUBLIC_KEY_ALGO, SHA256_HASH_ALGO])
+ struct.pack(">H", len(hashed))
+ hashed
)
trailer = b"\x04\xff" + struct.pack(">I", len(sig_data))
to_hash = signed_payload + sig_data + trailer
digest = hashlib.sha256(to_hash).digest()
signature = kms_sign(to_hash, args)
body = (
sig_data
+ struct.pack(">H", len(unhashed))
+ unhashed
+ digest[:2]
+ mpi(int.from_bytes(signature, "big"))
)
return packet_header(2, len(body)) + body
def public_key_command(args: argparse.Namespace) -> int:
created_at = args.created_at or int(time.time())
n, e = parse_rsa_public_key_from_pem(Path(args.public_key_pem))
public_body = public_key_body(n, e, created_at)
public_packet = packet_header(6, len(public_body)) + public_body
uid = args.user_id.encode("utf-8")
uid_packet = packet_header(13, len(uid)) + uid
uid_signed_payload = (
old_packet_header(6, len(public_body))
+ public_body
+ b"\xb4"
+ struct.pack(">I", len(uid))
+ uid
)
cert_packet = signature_packet(
uid_signed_payload,
public_body,
sig_type=0x13,
created_at=created_at,
args=args,
)
key = public_packet + uid_packet + cert_packet
output = Path(args.output)
if args.armor:
output.write_text(armor("PUBLIC KEY BLOCK", key), encoding="utf-8")
else:
output.write_bytes(key)
return 0
def detach_sign_command(args: argparse.Namespace) -> int:
created_at = args.created_at or int(time.time())
n, e = parse_rsa_public_key_from_pem(Path(args.public_key_pem))
public_body = public_key_body(n, e, args.key_created_at)
payload = Path(args.input).read_bytes()
sig_type = 0x01 if args.text else 0x00
packet = signature_packet(payload, public_body, sig_type, created_at, args)
output = Path(args.output)
if args.armor:
output.write_text(armor("SIGNATURE", packet), encoding="utf-8")
else:
output.write_bytes(packet)
return 0
def add_kms_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", "global"))
parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", "burrow-identity"))
parser.add_argument("--kms-key", required=not os.environ.get("BURROW_KMS_KEY"), default=os.environ.get("BURROW_KMS_KEY"))
parser.add_argument("--kms-version", default=os.environ.get("BURROW_KMS_VERSION", "1"))
parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT"))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subcommands = parser.add_subparsers(dest="command", required=True)
public_key = subcommands.add_parser("public-key")
public_key.add_argument("--public-key-pem", required=True)
public_key.add_argument("--user-id", default="Burrow Package Repository <packages@burrow.net>")
public_key.add_argument("--output", required=True)
public_key.add_argument("--armor", action="store_true")
public_key.add_argument("--created-at", type=int)
add_kms_args(public_key)
public_key.set_defaults(func=public_key_command)
detach = subcommands.add_parser("detach-sign")
detach.add_argument("--public-key-pem", required=True)
detach.add_argument("--key-created-at", type=int, default=1704067200)
detach.add_argument("--input", required=True)
detach.add_argument("--output", required=True)
detach.add_argument("--armor", action="store_true")
detach.add_argument("--text", action="store_true")
detach.add_argument("--created-at", type=int)
add_kms_args(detach)
detach.set_defaults(func=detach_sign_command)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return args.func(args)
except subprocess.CalledProcessError as exc:
print(f"gcloud signing failed: {exc}", file=sys.stderr)
return exc.returncode or 1
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -23,7 +23,7 @@ Options:
--no-refresh-token Reuse intake/forgejo_nsc_token.txt if it already exists. --no-refresh-token Reuse intake/forgejo_nsc_token.txt if it already exists.
--token-name <name> Forgejo PAT name prefix (default: forgejo-nsc) --token-name <name> Forgejo PAT name prefix (default: forgejo-nsc)
--contact-user <name> Forgejo username used for PAT creation (default: contact) --contact-user <name> Forgejo username used for PAT creation (default: contact)
--scope-owner <name> Forgejo org/user owner for the default NSC scope (default: burrow) --scope-owner <name> Forgejo org/user owner for the default NSC scope (default: hackclub)
--scope-name <name> Forgejo repository name for the default NSC scope (default: burrow) --scope-name <name> Forgejo repository name for the default NSC scope (default: burrow)
-h, --help Show this help text. -h, --help Show this help text.
EOF EOF
@ -36,7 +36,7 @@ KNOWN_HOSTS_FILE="${BURROW_FORGE_KNOWN_HOSTS_FILE:-${HOME}/.cache/burrow/forge-k
REFRESH_TOKEN=1 REFRESH_TOKEN=1
TOKEN_NAME_PREFIX="${FORGEJO_PAT_NAME:-forgejo-nsc}" TOKEN_NAME_PREFIX="${FORGEJO_PAT_NAME:-forgejo-nsc}"
CONTACT_USER="${FORGEJO_CONTACT_USER:-contact}" CONTACT_USER="${FORGEJO_CONTACT_USER:-contact}"
SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-burrow}" SCOPE_OWNER="${FORGEJO_SCOPE_OWNER:-hackclub}"
SCOPE_NAME="${FORGEJO_SCOPE_NAME:-burrow}" SCOPE_NAME="${FORGEJO_SCOPE_NAME:-burrow}"
BURROW_FLAKE_TMPDIRS=() BURROW_FLAKE_TMPDIRS=()

41
Scripts/releases-tofu.sh Executable file
View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
git rev-parse --show-toplevel 2>/dev/null || {
cd "$(dirname "${BASH_SOURCE[0]}")/.."
pwd
}
)"
work_dir="${BURROW_RELEASES_TOFU_DIR:-$ROOT/infra/releases}"
plugin_cache_dir="${BURROW_TOFU_PLUGIN_CACHE_DIR:-$ROOT/.cache/opentofu/plugins}"
usage() {
cat <<'EOF'
Usage: Scripts/releases-tofu.sh [tofu args...]
Runs OpenTofu for Burrow Google Cloud Storage release and package repository
buckets. Cloud provider credentials must come from ADC or Workload Identity
Federation; do not put service-account JSON in this repository.
Examples:
Scripts/releases-tofu.sh init -backend-config=backend.hcl
Scripts/releases-tofu.sh plan -var-file=terraform.tfvars
Local syntax check:
Scripts/releases-tofu.sh init -backend=false
Scripts/releases-tofu.sh validate
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$plugin_cache_dir"
export TF_PLUGIN_CACHE_DIR="$plugin_cache_dir"
export TF_IN_AUTOMATION="${TF_IN_AUTOMATION:-true}"
exec nix shell --inputs-from "$ROOT" nixpkgs#opentofu -c tofu -chdir="$work_dir" "$@"

172
Scripts/resolve-age-identity.sh Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env bash
set -euo pipefail
identity_is_valid() {
local path="$1"
if [[ -z "$path" || ! -f "$path" || ! -s "$path" ]]; then
return 1
fi
chmod 600 "$path" 2>/dev/null || true
if command -v age-keygen >/dev/null 2>&1 && age-keygen -y "$path" >/dev/null 2>&1; then
return 0
fi
if openssh_private_identity_has_markers "$path"; then
return 0
fi
if command -v ssh-keygen >/dev/null 2>&1 && ssh-keygen -y -f "$path" >/dev/null 2>&1; then
return 0
fi
local header
header="$(LC_ALL=C head -c 80 "$path" 2>/dev/null || true)"
case "$header" in
AGE-SECRET-KEY-*) return 0 ;;
esac
echo "::warning ::Ignoring invalid age identity candidate at ${path}" >&2
return 1
}
openssh_private_identity_has_markers() {
local path="$1"
local first_line
first_line="$(LC_ALL=C sed -n '1p' "$path" 2>/dev/null || true)"
if [[ "$first_line" != "-----BEGIN OPENSSH PRIVATE KEY-----" ]]; then
return 1
fi
if LC_ALL=C grep -Fq '\n' "$path" 2>/dev/null; then
return 1
fi
LC_ALL=C grep -q '^-----END OPENSSH PRIVATE KEY-----$' "$path" 2>/dev/null
}
candidate_path() {
local path="$1"
if identity_is_valid "$path"; then
printf '%s\n' "$path"
return 0
fi
return 1
}
preferred_home() {
if [[ -n "${RUNNER_HOME:-}" ]]; then
printf '%s\n' "$RUNNER_HOME"
elif [[ -n "${FORGEJO_RUNNER_HOME:-}" ]]; then
printf '%s\n' "$FORGEJO_RUNNER_HOME"
elif [[ -n "${HOME:-}" ]]; then
printf '%s\n' "$HOME"
elif [[ -n "${FORGEJO_RUNNER_WORKDIR:-}" ]]; then
printf '%s\n' "${FORGEJO_RUNNER_WORKDIR}/home"
else
printf '%s\n' "/tmp/forgejo-runner/home"
fi
}
decode_base64() {
local value="$1"
local path="$2"
if command -v base64 >/dev/null 2>&1; then
if printf '%s' "$value" | base64 -d > "$path" 2>/dev/null; then
return 0
fi
if printf '%s' "$value" | base64 -D > "$path" 2>/dev/null; then
return 0
fi
fi
return 1
}
materialize_secret() {
local raw="$1"
local home="$2"
local path="${home}/.ssh/age_keystore"
install -d -m 700 "${home}/.ssh"
printf '%s\n' "$raw" > "$path"
perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true
chmod 600 "$path"
if identity_is_valid "$path"; then
printf '%s\n' "$path"
return 0
fi
if [[ "$raw" == *"\\n"* ]]; then
printf '%b' "$raw" > "$path"
perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true
chmod 600 "$path"
if identity_is_valid "$path"; then
printf '%s\n' "$path"
return 0
fi
fi
if decode_base64 "$raw" "$path"; then
perl -0pi -e 's/\r\n?/\n/g' "$path" 2>/dev/null || true
chmod 600 "$path"
if identity_is_valid "$path"; then
printf '%s\n' "$path"
return 0
fi
fi
rm -f "$path"
echo "::warning ::Injected age identity was present but not parseable; trying other candidates" >&2
return 1
}
resolve_from_existing_files() {
local home
home="$(preferred_home)"
local candidate
while IFS= read -r candidate; do
candidate_path "$candidate" && return 0
done <<EOF
${AGE_IDENTITY_PATH:-}
${BURROW_RUNNER_AGE_IDENTITY_PATH:-}
/var/lib/forgejo-runner-agent/age_keystore
EOF
if [[ -n "${BURROW_RUNNER_AGE_IDENTITY_B64:-}" ]]; then
local decoded_path
decoded_path="${home}/.ssh/age_keystore.from_b64"
if decode_base64 "$BURROW_RUNNER_AGE_IDENTITY_B64" "$decoded_path" && candidate_path "$decoded_path"; then
return 0
fi
rm -f "$decoded_path"
if materialize_secret "$BURROW_RUNNER_AGE_IDENTITY_B64" "$home"; then
return 0
fi
fi
if [[ -n "${BURROW_RUNNER_AGE_IDENTITY:-}" ]]; then
materialize_secret "$BURROW_RUNNER_AGE_IDENTITY" "$home" && return 0
fi
if [[ -n "${AGE_FORGE_SSH_KEY:-}" ]]; then
materialize_secret "$AGE_FORGE_SSH_KEY" "$home" && return 0
fi
while IFS= read -r candidate; do
candidate_path "$candidate" && return 0
done <<EOF
${RUNNER_HOME:-}/.ssh/age_keystore
${FORGEJO_RUNNER_HOME:-}/.ssh/age_keystore
${HOME:-}/.ssh/age_keystore
${FORGEJO_RUNNER_WORKDIR:-}/home/.ssh/age_keystore
${FORGEJO_RUNNER_WORKDIR:-}/age_keystore
/tmp/forgejo-runner/home/.ssh/age_keystore
/tmp/forgejo-runner/age_keystore
EOF
return 1
}
if resolved="$(resolve_from_existing_files)"; then
printf 'AGE_IDENTITY_PATH=%s\n' "$resolved"
exit 0
fi
echo "::error ::Missing age identity (checked explicit path, runner home, managed host path, and injected secrets)" >&2
exit 1

121
Scripts/resolve-forge-ssh-key.sh Executable file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -euo pipefail
preferred_home() {
if [[ -n "${RUNNER_HOME:-}" ]]; then
printf '%s\n' "$RUNNER_HOME"
elif [[ -n "${FORGEJO_RUNNER_HOME:-}" ]]; then
printf '%s\n' "$FORGEJO_RUNNER_HOME"
elif [[ -n "${HOME:-}" ]]; then
printf '%s\n' "$HOME"
elif [[ -n "${FORGEJO_RUNNER_WORKDIR:-}" ]]; then
printf '%s\n' "${FORGEJO_RUNNER_WORKDIR}/home"
else
printf '%s\n' "/tmp/forgejo-runner/home"
fi
}
is_ssh_private_key() {
local candidate="$1"
[[ -n "$candidate" && -f "$candidate" && -s "$candidate" ]] || return 1
chmod 600 "$candidate" 2>/dev/null || true
ssh-keygen -y -f "$candidate" >/dev/null 2>&1
}
candidate_path() {
local path="$1"
if is_ssh_private_key "$path"; then
printf '%s\n' "$path"
return 0
fi
return 1
}
decode_base64() {
local value="$1"
local path="$2"
if command -v base64 >/dev/null 2>&1; then
if printf '%s' "$value" | base64 -d > "$path" 2>/dev/null; then
return 0
fi
if printf '%s' "$value" | base64 -D > "$path" 2>/dev/null; then
return 0
fi
fi
return 1
}
materialize_secret() {
local raw="$1"
local home="$2"
local path="${home}/.ssh/burrow_forge_ed25519"
install -d -m 700 "${home}/.ssh"
printf '%s\n' "$raw" > "$path"
if candidate_path "$path"; then
return 0
fi
if [[ "$raw" == *"\\n"* ]]; then
printf '%b' "$raw" > "$path"
if candidate_path "$path"; then
return 0
fi
fi
if decode_base64 "$raw" "$path" && candidate_path "$path"; then
return 0
fi
rm -f "$path"
echo "::warning ::Injected Forge SSH key was present but not parseable; trying file candidates" >&2
return 1
}
resolve_key() {
local home
home="$(preferred_home)"
if [[ -n "${BURROW_FORGE_SSH_KEY:-}" ]]; then
if candidate_path "$BURROW_FORGE_SSH_KEY"; then
return 0
fi
echo "::error ::BURROW_FORGE_SSH_KEY is set but is not an SSH private key: ${BURROW_FORGE_SSH_KEY}" >&2
return 1
fi
if [[ -n "${AGE_FORGE_SSH_KEY:-}" ]]; then
materialize_secret "$AGE_FORGE_SSH_KEY" "$home" && return 0
fi
local candidate
while IFS= read -r candidate; do
candidate_path "$candidate" && return 0
done <<EOF
${AGE_IDENTITY_PATH:-}
${BURROW_RUNNER_AGE_IDENTITY_PATH:-}
${RUNNER_HOME:-}/.ssh/burrow_forge_ed25519
${FORGEJO_RUNNER_HOME:-}/.ssh/burrow_forge_ed25519
${HOME:-}/.ssh/burrow_forge_ed25519
${HOME:-}/.ssh/agent_at_burrow_net_ed25519
${HOME:-}/.ssh/burrow_forgejo_ed25519
/var/lib/forgejo-runner-agent/age_keystore
${RUNNER_HOME:-}/.ssh/age_keystore
${FORGEJO_RUNNER_HOME:-}/.ssh/age_keystore
${HOME:-}/.ssh/age_keystore
${FORGEJO_RUNNER_WORKDIR:-}/home/.ssh/age_keystore
${FORGEJO_RUNNER_WORKDIR:-}/age_keystore
/tmp/forgejo-runner/home/.ssh/age_keystore
/tmp/forgejo-runner/age_keystore
EOF
return 1
}
if resolved="$(resolve_key)"; then
printf 'BURROW_FORGE_SSH_KEY=%s\n' "$resolved"
exit 0
fi
echo "::error ::Forge SSH key not found. Set BURROW_FORGE_SSH_KEY or AGE_FORGE_SSH_KEY." >&2
exit 1

261
Scripts/seal-release-secrets.sh Executable file
View file

@ -0,0 +1,261 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
usage() {
cat <<'EOF'
Usage: Scripts/seal-release-secrets.sh [--source-dir <dir>] [--only <secret>]
Encrypt Burrow release inputs into the agenix secrets consumed by CI.
Expected files in --source-dir (default: intake/release):
appstore-key-id.txt
appstore-key-issuer-id.txt
appstore-key.p8
distribution-cert.p12
distribution-cert-password.txt
sparkle-eddsa.key
Pre-composed appstore-connect.env, distribution-signing.env, and sparkle.env
files are also accepted.
Provisioning profiles are synced from App Store Connect credentials in CI.
The temporary keychain password is always the distribution certificate password.
Secrets:
all
appstore-connect
distribution-signing
sparkle
EOF
}
SOURCE_DIR="${BURROW_RELEASE_SECRET_SOURCE_DIR:-${REPO_ROOT}/intake/release}"
ONLY="all"
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir)
SOURCE_DIR="${2:?missing value for --source-dir}"
shift 2
;;
--only)
ONLY="${2:?missing value for --only}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 64
;;
esac
done
case "$ONLY" in
all|appstore-connect|app-store|appstore|distribution-signing|apple-signing|sparkle) ;;
*)
echo "unknown release secret selector: $ONLY" >&2
usage >&2
exit 64
;;
esac
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
require_cmd age
require_cmd nix
require_cmd python3
if [[ ! -d "$SOURCE_DIR" ]]; then
echo "release secret source directory does not exist: $SOURCE_DIR" >&2
exit 1
fi
tmpdir="$(mktemp -d)"
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT
seal_secret() {
local target="$1"
local source_path="$2"
local required="${3:-required}"
local recipients_file="${tmpdir}/$(basename "${target}").recipients"
if [[ ! -s "$source_path" ]]; then
if [[ "$required" == "optional" ]]; then
return 0
fi
echo "required release secret source missing or empty: $source_path" >&2
exit 1
fi
mkdir -p "${REPO_ROOT}/$(dirname "$target")"
nix eval --impure --json --expr "let s = import ${REPO_ROOT}/secrets.nix; in s.\"${target}\".publicKeys" \
| python3 -c 'import json, sys; [print(item) for item in json.load(sys.stdin)]' \
> "$recipients_file"
age -R "$recipients_file" -o "${REPO_ROOT}/${target}" "$source_path"
chmod 600 "${REPO_ROOT}/${target}"
echo "Sealed ${target}"
}
base64_file() {
python3 -c 'import base64, pathlib, sys; print(base64.b64encode(pathlib.Path(sys.argv[1]).read_bytes()).decode())' "$1"
}
read_secret_text() {
python3 -c 'import pathlib, sys; sys.stdout.write(pathlib.Path(sys.argv[1]).read_text().strip())' "$1"
}
base64_text() {
python3 -c 'import base64, sys; print(base64.b64encode(sys.stdin.buffer.read()).decode())'
}
reject_placeholder() {
local path="$1"
if grep -Eq '^(TODO|PASTE_|REPLACE_|#)' "$path"; then
echo "intake file still contains placeholder text: $path" >&2
exit 1
fi
}
reject_placeholder_text() {
local path="$1"
if grep -Iq . "$path"; then
reject_placeholder "$path"
fi
}
first_existing() {
local path
for path in "$@"; do
if [[ -s "$path" ]]; then
printf '%s\n' "$path"
return 0
fi
done
return 1
}
compose_appstore_connect_env() {
local direct="${SOURCE_DIR}/appstore-connect.env"
if [[ -s "$direct" ]]; then
printf '%s\n' "$direct"
return 0
fi
local key_id_file="${SOURCE_DIR}/appstore-key-id.txt"
local issuer_id_file="${SOURCE_DIR}/appstore-key-issuer-id.txt"
local key_file="${SOURCE_DIR}/appstore-key.p8"
if [[ ! -s "$key_file" ]]; then
echo "required App Store Connect source missing or empty: appstore-key.p8" >&2
exit 1
fi
for path in "$key_id_file" "$issuer_id_file" "$key_file"; do
if [[ ! -s "$path" ]]; then
echo "required App Store Connect source missing or empty: $path" >&2
exit 1
fi
reject_placeholder_text "$path"
done
local out="${tmpdir}/appstore-connect.env"
{
printf 'ASC_API_KEY_ID=%s\n' "$(read_secret_text "$key_id_file")"
printf 'ASC_API_ISSUER_ID=%s\n' "$(read_secret_text "$issuer_id_file")"
printf 'ASC_API_KEY_BASE64=%s\n' "$(base64_file "$key_file")"
} > "$out"
printf '%s\n' "$out"
}
compose_distribution_signing_env() {
local direct="${SOURCE_DIR}/distribution-signing.env"
if [[ -s "$direct" ]]; then
printf '%s\n' "$direct"
return 0
fi
local cert_file
cert_file="${SOURCE_DIR}/distribution-cert.p12"
local password_file="${SOURCE_DIR}/distribution-cert-password.txt"
if [[ ! -s "$cert_file" ]]; then
echo "required distribution signing source missing or empty: distribution-cert.p12" >&2
exit 1
fi
for path in "$password_file"; do
if [[ ! -s "$path" ]]; then
echo "required distribution signing source missing or empty: $path" >&2
exit 1
fi
reject_placeholder_text "$path"
done
local out="${tmpdir}/distribution-signing.env"
{
printf 'APPLE_SIGNING_CERTIFICATE_BASE64=%s\n' "$(base64_file "$cert_file")"
printf 'APPLE_SIGNING_CERTIFICATE_PASSWORD_BASE64=%s\n' "$(read_secret_text "$password_file" | base64_text)"
} > "$out"
printf '%s\n' "$out"
}
compose_sparkle_env() {
local direct="${SOURCE_DIR}/sparkle.env"
if [[ -s "$direct" ]]; then
printf '%s\n' "$direct"
return 0
fi
local key_file
key_file="${SOURCE_DIR}/sparkle-eddsa.key"
if [[ ! -s "$key_file" ]]; then
return 1
fi
reject_placeholder_text "$key_file"
local out="${tmpdir}/sparkle.env"
printf 'SPARKLE_EDDSA_KEY_BASE64=%s\n' "$(base64_file "$key_file")" > "$out"
printf '%s\n' "$out"
}
case "$ONLY" in
all)
appstore_env="$(compose_appstore_connect_env)"
seal_secret "secrets/apple/appstore-connect.env.age" "$appstore_env"
distribution_env="$(compose_distribution_signing_env)"
seal_secret "secrets/apple/distribution-signing.env.age" "$distribution_env"
if sparkle_env="$(compose_sparkle_env)"; then
seal_secret "secrets/apple/sparkle.env.age" "$sparkle_env"
fi
;;
appstore-connect|app-store|appstore)
appstore_env="$(compose_appstore_connect_env)"
seal_secret "secrets/apple/appstore-connect.env.age" "$appstore_env"
;;
distribution-signing|apple-signing)
distribution_env="$(compose_distribution_signing_env)"
seal_secret "secrets/apple/distribution-signing.env.age" "$distribution_env"
;;
sparkle)
if sparkle_env="$(compose_sparkle_env)"; then
seal_secret "secrets/apple/sparkle.env.age" "$sparkle_env"
else
echo "required Sparkle source missing or empty: sparkle-eddsa.key" >&2
exit 1
fi
;;
esac
echo "Release secrets are sealed. Commit the .age files and deploy/use CI with an authorized age identity."

View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Sign Sparkle appcast enclosures with a Google Cloud KMS Ed25519 key."""
from __future__ import annotations
import argparse
import base64
import os
import subprocess
import sys
import tempfile
import urllib.parse
import xml.etree.ElementTree as ET
from pathlib import Path
SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle"
DEFAULT_PROJECT = "project-88c23ce9-918a-470a-b33"
DEFAULT_LOCATION = "global"
DEFAULT_KEYRING = "burrow-identity"
DEFAULT_KEY = "sparkle-ed25519"
DEFAULT_VERSION = "1"
def run(command: list[str]) -> None:
subprocess.run(command, check=True)
def kms_sign(args: argparse.Namespace, payload: Path) -> bytes:
with tempfile.TemporaryDirectory(prefix="burrow-sparkle-kms.") as tmp:
signature_path = Path(tmp) / "signature.bin"
command = [
args.gcloud,
"kms",
"asymmetric-sign",
"--location",
args.kms_location,
"--keyring",
args.kms_keyring,
"--key",
args.kms_key,
"--version",
args.kms_version,
"--input-file",
str(payload),
"--signature-file",
str(signature_path),
]
if args.gcloud_project:
command.extend(["--project", args.gcloud_project])
run(command)
return signature_path.read_bytes()
def enclosure_path(enclosure: ET.Element, artifact_dir: Path) -> Path:
url = enclosure.attrib.get("url")
if not url:
raise ValueError("Sparkle enclosure is missing a url attribute")
parsed = urllib.parse.urlparse(url)
name = Path(parsed.path).name
if not name:
raise ValueError(f"Sparkle enclosure URL has no file name: {url}")
path = artifact_dir / name
if not path.is_file():
raise FileNotFoundError(f"Sparkle enclosure artifact not found: {path}")
return path
def sign_appcast(args: argparse.Namespace) -> None:
appcast = Path(args.appcast)
artifact_dir = Path(args.artifact_dir) if args.artifact_dir else appcast.parent
ET.register_namespace("sparkle", SPARKLE_NS)
tree = ET.parse(appcast)
root = tree.getroot()
enclosures = root.findall(".//enclosure")
if not enclosures:
raise ValueError(f"no Sparkle enclosures found in {appcast}")
for enclosure in enclosures:
artifact = enclosure_path(enclosure, artifact_dir)
signature = kms_sign(args, artifact)
enclosure.attrib[f"{{{SPARKLE_NS}}}edSignature"] = base64.b64encode(signature).decode("ascii")
tree.write(appcast, encoding="utf-8", xml_declaration=True)
print(f"signed {len(enclosures)} enclosure(s) in {appcast}")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sign Sparkle appcast enclosures with Google Cloud KMS.")
parser.add_argument("--appcast", required=True)
parser.add_argument("--artifact-dir")
parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("BURROW_GOOGLE_PROJECT_ID") or DEFAULT_PROJECT)
parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", DEFAULT_LOCATION))
parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", DEFAULT_KEYRING))
parser.add_argument("--kms-key", default=os.environ.get("BURROW_SPARKLE_KMS_KEY", DEFAULT_KEY))
parser.add_argument("--kms-version", default=os.environ.get("BURROW_SPARKLE_KMS_VERSION", DEFAULT_VERSION))
parser.add_argument("--gcloud", default=os.environ.get("GCLOUD", "gcloud"))
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
sign_appcast(parse_args(argv))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

146
Scripts/version.sh Executable file
View file

@ -0,0 +1,146 @@
#!/usr/bin/env bash
set -euo pipefail
export PATH="$PATH:/opt/homebrew/bin:/usr/local/bin:/etc/profiles/per-user/$USER/bin"
cd "$(dirname "${BASH_SOURCE[0]}")/.."
TAG_PREFIX="builds/"
DEFAULT_VERSION_REMOTE="origin"
if git remote get-url forgejo >/dev/null 2>&1; then
DEFAULT_VERSION_REMOTE="forgejo"
fi
VERSION_REMOTE="${BURROW_VERSION_REMOTE:-$DEFAULT_VERSION_REMOTE}"
VERSION_REQUIRE_REMOTE="${BURROW_VERSION_REQUIRE_REMOTE:-false}"
COMMAND="${1:-}"
truthy() {
case "$1" in
1|true|TRUE|True|yes|YES|Yes|y|Y|on|ON|On) return 0 ;;
*) return 1 ;;
esac
}
list_local_build_tags() {
git tag -l "${TAG_PREFIX}[0-9]*"
}
latest_local_build_tag() {
list_local_build_tags | sort -n -t'/' -k2 | tail -n 1
}
latest_remote_build_record() {
local raw rc=0
raw="$(git ls-remote --tags --refs "$VERSION_REMOTE" "refs/tags/${TAG_PREFIX}[0-9]*" 2>/dev/null)" || rc=$?
if [[ $rc -ne 0 ]]; then
return $rc
fi
if [[ -z "$raw" ]]; then
return 0
fi
printf '%s\n' "$raw" | awk -F'\t' -v prefix="refs/tags/${TAG_PREFIX}" '
$2 ~ ("^" prefix "[0-9]+$") {
build = $2
sub("^refs/tags/", "", build)
print build "\t" $1
}
' | sort -n -t'/' -k2 | tail -n 1
}
resolve_build_state() {
local remote_record local_latest remote_status=0
BUILD_STATE_SOURCE="local"
LATEST_BUILD=""
LATEST_BUILD_COMMIT=""
remote_record="$(latest_remote_build_record)" || remote_status=$?
if [[ $remote_status -eq 0 ]]; then
BUILD_STATE_SOURCE="remote"
if [[ -n "$remote_record" ]]; then
LATEST_BUILD="$(printf '%s\n' "$remote_record" | cut -f1)"
LATEST_BUILD_COMMIT="$(printf '%s\n' "$remote_record" | cut -f2)"
fi
else
if truthy "$VERSION_REQUIRE_REMOTE"; then
echo "error: unable to resolve authoritative build tags from ${VERSION_REMOTE}" >&2
exit "$remote_status"
fi
echo "warning: unable to resolve authoritative build tags from ${VERSION_REMOTE}; falling back to local tags" >&2
local_latest="$(latest_local_build_tag)"
if [[ -n "$local_latest" ]]; then
LATEST_BUILD="$local_latest"
LATEST_BUILD_COMMIT="$(git rev-parse -q --verify "refs/tags/${LATEST_BUILD}^{commit}" 2>/dev/null || true)"
fi
fi
if [[ -z "$LATEST_BUILD" ]]; then
LATEST_BUILD_NUMBER="0"
else
LATEST_BUILD_NUMBER="${LATEST_BUILD#$TAG_PREFIX}"
fi
}
resolve_build_state
HEAD_COMMIT="$(git rev-parse HEAD)"
ANCESTRY_STATE="ok"
if [[ -n "${LATEST_BUILD_NUMBER}" && "${LATEST_BUILD_NUMBER}" != "0" ]]; then
if [[ -n "${LATEST_BUILD_COMMIT}" ]] && git cat-file -e "${LATEST_BUILD_COMMIT}^{commit}" 2>/dev/null; then
if ! git merge-base --is-ancestor "${LATEST_BUILD_COMMIT}" HEAD; then
ANCESTRY_STATE="not-descended"
if [[ "$COMMAND" != "status" ]]; then
echo "error: HEAD is not descended from build ${LATEST_BUILD_NUMBER}" >&2
exit 1
fi
fi
else
ANCESTRY_STATE="missing-tag-commit"
echo "notice: skipping build ancestry check (missing ${LATEST_BUILD} commit, likely shallow checkout)" >&2
fi
fi
BUILD_NUMBER="$LATEST_BUILD_NUMBER"
if [[ "$COMMAND" == "increment" || "$COMMAND" == "pre-increment" ]]; then
if [[ -n "${BURROW_BUILD_NUMBER_OVERRIDE:-}" ]]; then
if [[ ! "${BURROW_BUILD_NUMBER_OVERRIDE}" =~ ^[0-9]+$ ]]; then
echo "error: BURROW_BUILD_NUMBER_OVERRIDE must be an integer" >&2
exit 1
fi
if (( BURROW_BUILD_NUMBER_OVERRIDE <= LATEST_BUILD_NUMBER )); then
echo "error: BURROW_BUILD_NUMBER_OVERRIDE must be greater than ${LATEST_BUILD_NUMBER}" >&2
exit 1
fi
NEW_BUILD_NUMBER="${BURROW_BUILD_NUMBER_OVERRIDE}"
else
NEW_BUILD_NUMBER=$((LATEST_BUILD_NUMBER + 1))
fi
NEW_TAG="$TAG_PREFIX$NEW_BUILD_NUMBER"
BUILD_NUMBER="$NEW_BUILD_NUMBER"
fi
if [[ "$COMMAND" == "increment" ]]; then
git tag "$NEW_TAG"
git push --quiet "$VERSION_REMOTE" "$NEW_TAG"
fi
CONFIG_PATH="Apple/Configuration/Version.xcconfig"
if [[ "$COMMAND" != "status" && -f "$CONFIG_PATH" ]]; then
current_config_version="$(sed -n 's/^[[:space:]]*CURRENT_PROJECT_VERSION[[:space:]]*=[[:space:]]*//p' "$CONFIG_PATH" 2>/dev/null | tail -n 1 || true)"
if [[ "$current_config_version" != "$BUILD_NUMBER" ]]; then
echo "CURRENT_PROJECT_VERSION = $BUILD_NUMBER" > "$CONFIG_PATH"
fi
git update-index --assume-unchanged "$CONFIG_PATH" 2>/dev/null || true
fi
if [[ "$COMMAND" == "status" ]]; then
if [[ "${ANCESTRY_STATE}" == "ok" && "${LATEST_BUILD_NUMBER}" != "0" && -n "${LATEST_BUILD_COMMIT}" && "${HEAD_COMMIT}" == "${LATEST_BUILD_COMMIT}" ]]; then
echo "clean"
else
echo "dirty"
fi
exit 0
fi
echo "$BUILD_NUMBER"

14
bazel/android/BUILD.bazel Normal file
View file

@ -0,0 +1,14 @@
load("@rules_shell//shell:sh_binary.bzl", "sh_binary")
load(":android_targets.bzl", "android_stub_stamp")
package(default_visibility = ["//visibility:public"])
sh_binary(
name = "runner",
srcs = ["runner.sh"],
)
android_stub_stamp(
name = "check_stub_stamp",
command = "check-stub",
)

View file

@ -0,0 +1,12 @@
def android_stub_stamp(name, command):
native.genrule(
name = name,
outs = [name + ".stamp"],
cmd = "$(location :runner) %s && : > \"$@\"" % command,
srcs = [
"//:android_project_files",
"//:mobile_rust_core_files",
],
tools = [":runner"],
tags = ["no-sandbox"],
)

73
bazel/android/runner.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
command="${1:-}"
resolve_repo_root() {
if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" && -f "${BUILD_WORKSPACE_DIRECTORY}/MODULE.bazel" ]]; then
printf '%s\n' "$BUILD_WORKSPACE_DIRECTORY"
return 0
fi
local dir="$PWD"
while [[ "$dir" != "/" ]]; do
if [[ -f "$dir/MODULE.bazel" && -d "$dir/Android" ]]; then
printf '%s\n' "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
echo "unable to resolve Burrow repository root" >&2
return 1
}
repo_root="$(resolve_repo_root)"
cd "$repo_root"
cargo_check_locked() {
local -a cargo_path_prefixes
local home_dir user_name
cargo_path_prefixes=()
home_dir="${HOME:-}"
if [[ -n "$home_dir" ]]; then
cargo_path_prefixes+=("$home_dir/.cargo/bin")
fi
user_name="$(id -un 2>/dev/null || true)"
if [[ -n "$user_name" ]]; then
cargo_path_prefixes+=(
"/Users/${user_name}/.cargo/bin"
"/home/${user_name}/.cargo/bin"
"/etc/profiles/per-user/${user_name}/bin"
)
fi
cargo_path_prefixes+=("/opt/homebrew/bin" "/usr/local/bin" "/nix/var/nix/profiles/default/bin" "/run/current-system/sw/bin")
export PATH="$(IFS=:; printf '%s' "${cargo_path_prefixes[*]}"):$PATH"
if command -v cargo >/dev/null 2>&1; then
cargo check --locked -p burrow-mobile-ffi
return
fi
if [[ "${BURROW_ANDROID_BAZEL_USE_NIX:-0}" == "1" ]] && command -v nix >/dev/null 2>&1; then
nix develop .#ci -c cargo check --locked -p burrow-mobile-ffi
return
fi
echo "cargo or nix is required to check the Android Rust core stub" >&2
exit 127
}
case "$command" in
check-stub)
test -f Android/settings.gradle.kts
test -f Android/app/src/main/java/net/burrow/android/MainActivity.kt
test -f crates/burrow-core/src/lib.rs
test -f crates/burrow-mobile-ffi/src/lib.rs
cargo_check_locked
;;
*)
echo "unknown Android runner command: ${command}" >&2
exit 64
;;
esac

25
bazel/apple/BUILD.bazel Normal file
View file

@ -0,0 +1,25 @@
load("@rules_shell//shell:sh_binary.bzl", "sh_binary")
load(":apple_targets.bzl", "apple_bazel_stamp")
package(default_visibility = ["//visibility:public"])
sh_binary(
name = "runner",
srcs = ["runner.sh"],
target_compatible_with = ["@platforms//os:macos"],
)
apple_bazel_stamp(
name = "release_ios_stamp",
command = "release-ios",
)
apple_bazel_stamp(
name = "release_macos_stamp",
command = "release-macos",
)
apple_bazel_stamp(
name = "release_all_stamp",
command = "release-all",
)

View file

@ -0,0 +1,13 @@
def apple_bazel_stamp(name, command):
native.genrule(
name = name,
outs = [name + ".stamp"],
cmd = "$(location :runner) %s && : > \"$@\"" % command,
srcs = ["//:apple_project_files"],
tools = [":runner"],
# The Xcode script stages release artifacts under dist/builds as a
# deliberate CI side effect; keep the wrapper uncached so the staging
# step always runs in fresh and reused workspaces.
tags = ["no-cache", "no-remote-cache", "no-sandbox"],
target_compatible_with = ["@platforms//os:macos"],
)

42
bazel/apple/runner.sh Executable file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
command="${1:-}"
resolve_repo_root() {
if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" && -f "${BUILD_WORKSPACE_DIRECTORY}/MODULE.bazel" ]]; then
printf '%s\n' "$BUILD_WORKSPACE_DIRECTORY"
return 0
fi
local dir="$PWD"
while [[ "$dir" != "/" ]]; do
if [[ -f "$dir/MODULE.bazel" && -d "$dir/Apple" ]]; then
printf '%s\n' "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
echo "unable to resolve Burrow repository root" >&2
return 1
}
repo_root="$(resolve_repo_root)"
cd "$repo_root"
case "$command" in
release-ios)
Scripts/ci/package-apple-artifacts.sh ios
;;
release-macos)
Scripts/ci/package-apple-artifacts.sh macos
;;
release-all)
Scripts/ci/package-apple-artifacts.sh all
;;
*)
echo "unknown apple runner command: ${command}" >&2
exit 64
;;
esac

View file

@ -0,0 +1,515 @@
use super::*;
use anyhow::{Context, Result};
use tokio::time::Duration;
pub struct NetworksScreen {
list_box: gtk::ListBox,
network_status: gtk::Label,
wireguard_text: gtk::TextView,
tailnet_email_entry: gtk::Entry,
tailnet_authority_entry: gtk::Entry,
tailnet_account_entry: gtk::Entry,
tailnet_identity_entry: gtk::Entry,
tailnet_hostname_entry: gtk::Entry,
tailnet_name_entry: gtk::Entry,
custom_authority_switch: gtk::Switch,
tailnet_status: gtk::Label,
tailnet_session_id: Option<String>,
tailnet_running: bool,
}
#[derive(Debug)]
pub enum NetworksScreenMsg {
Refresh,
AddWireGuard,
DiscoverTailnet,
ProbeTailnet,
StartTailnetLogin,
PollTailnetLogin,
CancelTailnetLogin,
AddTailnet,
}
#[relm4::component(pub, async)]
impl AsyncComponent for NetworksScreen {
type Init = ();
type Input = NetworksScreenMsg;
type Output = ();
type CommandOutput = ();
view! {
gtk::ScrolledWindow {
set_vexpand: true,
adw::Clamp {
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 12,
set_margin_all: 12,
adw::PreferencesGroup {
set_title: "Stored Networks",
set_description: Some("Networks saved in the Burrow daemon"),
#[name(network_status)]
gtk::Label {
set_xalign: 0.0,
set_wrap: true,
set_label: "Loading networks",
},
#[name(list_box)]
gtk::ListBox {
add_css_class: "boxed-list",
},
gtk::Button {
set_label: "Refresh",
connect_clicked => NetworksScreenMsg::Refresh
},
},
adw::PreferencesGroup {
set_title: "Add WireGuard",
set_description: Some("Import a WireGuard configuration into the daemon"),
gtk::ScrolledWindow {
set_min_content_height: 150,
set_vexpand: false,
#[name(wireguard_text)]
gtk::TextView {
set_monospace: true,
set_wrap_mode: gtk::WrapMode::WordChar,
}
},
gtk::Button {
set_label: "Add WireGuard Network",
connect_clicked => NetworksScreenMsg::AddWireGuard
},
},
adw::PreferencesGroup {
set_title: "Tailnet",
set_description: Some("Discover, probe, sign in, and save Tailnet authorities through the daemon"),
#[name(tailnet_email_entry)]
gtk::Entry {
set_placeholder_text: Some("Email address"),
},
#[name(custom_authority_switch)]
gtk::Switch {
set_halign: Align::Start,
set_active: false,
},
#[name(tailnet_authority_entry)]
gtk::Entry {
set_placeholder_text: Some("Tailnet server URL"),
},
#[name(tailnet_account_entry)]
gtk::Entry {
set_placeholder_text: Some("Account name"),
},
#[name(tailnet_identity_entry)]
gtk::Entry {
set_placeholder_text: Some("Identity name"),
},
#[name(tailnet_hostname_entry)]
gtk::Entry {
set_placeholder_text: Some("Device hostname"),
},
#[name(tailnet_name_entry)]
gtk::Entry {
set_placeholder_text: Some("Tailnet name"),
},
#[name(tailnet_status)]
gtk::Label {
set_xalign: 0.0,
set_wrap: true,
set_selectable: true,
set_label: "Use discovery for the managed Tailscale authority or enter a custom Tailnet server.",
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 8,
gtk::Button {
set_label: "Discover",
connect_clicked => NetworksScreenMsg::DiscoverTailnet
},
gtk::Button {
set_label: "Probe",
connect_clicked => NetworksScreenMsg::ProbeTailnet
},
gtk::Button {
set_label: "Sign In",
connect_clicked => NetworksScreenMsg::StartTailnetLogin
},
gtk::Button {
set_label: "Cancel",
connect_clicked => NetworksScreenMsg::CancelTailnetLogin
},
},
gtk::Button {
set_label: "Save Tailnet Network",
connect_clicked => NetworksScreenMsg::AddTailnet
},
},
}
}
}
}
async fn init(
_: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let widgets = view_output!();
widgets
.tailnet_authority_entry
.set_text(daemon_api::default_tailnet_authority());
widgets.tailnet_account_entry.set_text("default");
widgets.tailnet_identity_entry.set_text("linux");
let mut model = NetworksScreen {
list_box: widgets.list_box.clone(),
network_status: widgets.network_status.clone(),
wireguard_text: widgets.wireguard_text.clone(),
tailnet_email_entry: widgets.tailnet_email_entry.clone(),
tailnet_authority_entry: widgets.tailnet_authority_entry.clone(),
tailnet_account_entry: widgets.tailnet_account_entry.clone(),
tailnet_identity_entry: widgets.tailnet_identity_entry.clone(),
tailnet_hostname_entry: widgets.tailnet_hostname_entry.clone(),
tailnet_name_entry: widgets.tailnet_name_entry.clone(),
custom_authority_switch: widgets.custom_authority_switch.clone(),
tailnet_status: widgets.tailnet_status.clone(),
tailnet_session_id: None,
tailnet_running: false,
};
model.refresh_networks().await;
AsyncComponentParts { model, widgets }
}
async fn update(
&mut self,
msg: Self::Input,
sender: AsyncComponentSender<Self>,
_root: &Self::Root,
) {
match msg {
NetworksScreenMsg::Refresh => self.refresh_networks().await,
NetworksScreenMsg::AddWireGuard => self.add_wireguard().await,
NetworksScreenMsg::DiscoverTailnet => self.discover_tailnet().await,
NetworksScreenMsg::ProbeTailnet => self.probe_tailnet().await,
NetworksScreenMsg::StartTailnetLogin => self.start_tailnet_login(sender).await,
NetworksScreenMsg::PollTailnetLogin => self.poll_tailnet_login(sender).await,
NetworksScreenMsg::CancelTailnetLogin => self.cancel_tailnet_login().await,
NetworksScreenMsg::AddTailnet => self.add_tailnet().await,
}
}
}
impl NetworksScreen {
async fn refresh_networks(&mut self) {
match daemon_api::list_networks().await {
Ok(networks) => {
self.render_networks(&networks);
self.network_status.set_label(if networks.is_empty() {
"No daemon networks saved yet"
} else {
"Daemon networks are up to date"
});
}
Err(error) => {
self.clear_networks();
self.network_status
.set_label(&format!("Unable to read daemon networks: {error}"));
}
}
}
async fn add_wireguard(&mut self) {
let config = text_view_text(&self.wireguard_text);
if config.trim().is_empty() {
self.network_status
.set_label("Paste a WireGuard configuration before adding a network");
return;
}
match daemon_api::add_wireguard(config).await {
Ok(id) => {
self.network_status
.set_label(&format!("Added WireGuard network #{id}"));
self.wireguard_text.buffer().set_text("");
self.refresh_networks().await;
}
Err(error) => self
.network_status
.set_label(&format!("Unable to add WireGuard network: {error}")),
}
}
async fn discover_tailnet(&mut self) {
let email = self.tailnet_email_entry.text().to_string();
let Ok(email) = daemon_api::require_value(&email, "Email address") else {
self.tailnet_status
.set_label("Enter an email address before discovery");
return;
};
self.tailnet_status.set_label("Discovering Tailnet server");
match daemon_api::discover_tailnet(email).await {
Ok(discovery) => {
self.tailnet_authority_entry.set_text(&discovery.authority);
self.custom_authority_switch.set_active(!discovery.managed);
let issuer = discovery
.oidc_issuer
.map(|issuer| format!(" OIDC issuer: {issuer}."))
.unwrap_or_default();
let kind = if discovery.managed {
"managed authority"
} else {
"custom authority"
};
self.tailnet_status.set_label(&format!(
"Discovered {kind}: {}.{issuer}",
discovery.authority
));
}
Err(error) => self
.tailnet_status
.set_label(&format!("Tailnet discovery failed: {error}")),
}
}
async fn probe_tailnet(&mut self) {
let authority = self.tailnet_authority();
let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else {
self.tailnet_status
.set_label("Enter a Tailnet server URL before probing");
return;
};
self.tailnet_status.set_label("Checking Tailnet server");
match daemon_api::probe_tailnet(authority).await {
Ok(probe) => {
let detail = probe
.detail
.unwrap_or_else(|| format!("HTTP {}", probe.status_code));
self.tailnet_status
.set_label(&format!("{}: {detail}", probe.summary));
}
Err(error) => self
.tailnet_status
.set_label(&format!("Tailnet probe failed: {error}")),
}
}
async fn start_tailnet_login(&mut self, sender: AsyncComponentSender<Self>) {
let authority = self.tailnet_authority();
let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else {
self.tailnet_status
.set_label("Enter a Tailnet server URL before sign-in");
return;
};
let account = daemon_api::normalized(&self.tailnet_account_entry.text(), "default");
let identity = daemon_api::normalized(&self.tailnet_identity_entry.text(), "linux");
let hostname = daemon_api::normalized_optional(&self.tailnet_hostname_entry.text());
self.tailnet_status.set_label("Starting Tailnet sign-in");
match daemon_api::start_tailnet_login(authority, account, identity, hostname).await {
Ok(status) => {
self.apply_login_status(&status);
if let Some(auth_url) = status.auth_url.as_deref() {
if let Err(error) = open_auth_url(auth_url) {
self.tailnet_status.set_label(&format!(
"{} Open this URL manually: {auth_url}. Browser launch failed: {error}",
self.tailnet_status.text()
));
}
}
if !status.running {
sender.input(NetworksScreenMsg::PollTailnetLogin);
}
}
Err(error) => self
.tailnet_status
.set_label(&format!("Tailnet sign-in failed: {error}")),
}
}
async fn poll_tailnet_login(&mut self, sender: AsyncComponentSender<Self>) {
let Some(session_id) = self.tailnet_session_id.clone() else {
return;
};
if self.tailnet_running {
return;
}
tokio::time::sleep(Duration::from_secs(1)).await;
match daemon_api::tailnet_login_status(session_id).await {
Ok(status) => {
self.apply_login_status(&status);
if !status.running {
sender.input(NetworksScreenMsg::PollTailnetLogin);
}
}
Err(error) => {
self.tailnet_status
.set_label(&format!("Tailnet sign-in status failed: {error}"));
self.tailnet_session_id = None;
}
}
}
async fn cancel_tailnet_login(&mut self) {
let Some(session_id) = self.tailnet_session_id.clone() else {
self.tailnet_status
.set_label("No Tailnet sign-in is active");
return;
};
match daemon_api::cancel_tailnet_login(session_id).await {
Ok(()) => {
self.tailnet_session_id = None;
self.tailnet_running = false;
self.tailnet_status.set_label("Tailnet sign-in cancelled");
}
Err(error) => self
.tailnet_status
.set_label(&format!("Unable to cancel Tailnet sign-in: {error}")),
}
}
async fn add_tailnet(&mut self) {
let authority = self.tailnet_authority();
let Ok(authority) = daemon_api::require_value(&authority, "Tailnet server URL") else {
self.tailnet_status
.set_label("Enter a Tailnet server URL before saving");
return;
};
if self.tailnet_session_id.is_some() && !self.tailnet_running {
self.tailnet_status
.set_label("Finish browser sign-in before saving this Tailnet network");
return;
}
let account = daemon_api::normalized(&self.tailnet_account_entry.text(), "default");
let identity = daemon_api::normalized(&self.tailnet_identity_entry.text(), "linux");
let hostname = daemon_api::normalized_optional(&self.tailnet_hostname_entry.text());
let tailnet = daemon_api::normalized_optional(&self.tailnet_name_entry.text());
match daemon_api::add_tailnet(authority, account, identity, hostname, tailnet).await {
Ok(id) => {
self.tailnet_status
.set_label(&format!("Saved Tailnet network #{id}"));
self.refresh_networks().await;
}
Err(error) => self
.tailnet_status
.set_label(&format!("Unable to save Tailnet network: {error}")),
}
}
fn apply_login_status(&mut self, status: &daemon_api::TailnetLoginStatus) {
self.tailnet_session_id = Some(status.session_id.clone());
self.tailnet_running = status.running;
let mut parts = Vec::new();
if status.running {
parts.push("Signed in".to_owned());
} else if status.needs_login {
parts.push("Browser sign-in required".to_owned());
} else {
parts.push("Checking sign-in".to_owned());
}
if !status.backend_state.is_empty() {
parts.push(format!("State: {}", status.backend_state));
}
if let Some(tailnet_name) = &status.tailnet_name {
parts.push(format!("Tailnet: {tailnet_name}"));
}
if let Some(self_dns_name) = &status.self_dns_name {
parts.push(self_dns_name.clone());
}
if !status.tailnet_ips.is_empty() {
parts.push(status.tailnet_ips.join(", "));
}
if !status.health.is_empty() {
parts.push(status.health.join(" / "));
}
if let Some(auth_url) = &status.auth_url {
parts.push(format!("Auth URL: {auth_url}"));
}
self.tailnet_status.set_label(&parts.join("\n"));
}
fn tailnet_authority(&self) -> String {
if self.custom_authority_switch.is_active() {
self.tailnet_authority_entry.text().to_string()
} else {
let authority = self.tailnet_authority_entry.text();
if authority.trim().is_empty() {
daemon_api::default_tailnet_authority().to_owned()
} else {
authority.to_string()
}
}
}
fn clear_networks(&self) {
while let Some(child) = self.list_box.first_child() {
self.list_box.remove(&child);
}
}
fn render_networks(&self, networks: &[daemon_api::NetworkSummary]) {
self.clear_networks();
if networks.is_empty() {
let row = adw::ActionRow::builder()
.title("No networks")
.subtitle("Save a WireGuard or Tailnet network to use it here.")
.build();
self.list_box.append(&row);
return;
}
for network in networks {
let title = format!("{} (#{})", network.title, network.id);
let row = adw::ActionRow::builder()
.title(&title)
.subtitle(&network.detail)
.build();
self.list_box.append(&row);
}
}
}
fn text_view_text(text_view: &gtk::TextView) -> String {
let buffer = text_view.buffer();
buffer
.text(&buffer.start_iter(), &buffer.end_iter(), true)
.to_string()
}
fn open_auth_url(url: &str) -> Result<()> {
gtk::gio::AppInfo::launch_default_for_uri(url, None::<&gtk::gio::AppLaunchContext>)
.with_context(|| format!("failed to open {url}"))
}

View file

@ -4,12 +4,10 @@ use std::process::Command;
#[derive(Debug)] #[derive(Debug)]
pub struct DaemonGroup { pub struct DaemonGroup {
system_setup: SystemSetup, system_setup: SystemSetup,
daemon_client: Arc<Mutex<Option<DaemonClient>>>,
already_running: bool, already_running: bool,
} }
pub struct DaemonGroupInit { pub struct DaemonGroupInit {
pub daemon_client: Arc<Mutex<Option<DaemonClient>>>,
pub system_setup: SystemSetup, pub system_setup: SystemSetup,
} }
@ -51,8 +49,7 @@ impl AsyncComponent for DaemonGroup {
// Should be impossible to panic here // Should be impossible to panic here
let model = DaemonGroup { let model = DaemonGroup {
system_setup: init.system_setup, system_setup: init.system_setup,
daemon_client: init.daemon_client.clone(), already_running: daemon_api::daemon_available().await,
already_running: init.daemon_client.lock().await.is_some(),
}; };
let widgets = view_output!(); let widgets = view_output!();
@ -104,7 +101,7 @@ mv $TEMP /tmp/burrow-detached-daemon"#,
.unwrap(); .unwrap();
} }
DaemonGroupMsg::DaemonStateChange => { DaemonGroupMsg::DaemonStateChange => {
self.already_running = self.daemon_client.lock().await.is_some(); self.already_running = daemon_api::daemon_available().await;
} }
} }
} }

View file

@ -2,8 +2,6 @@ use super::*;
#[derive(Debug)] #[derive(Debug)]
pub struct DiagGroup { pub struct DiagGroup {
daemon_client: Arc<Mutex<Option<DaemonClient>>>,
system_setup: SystemSetup, system_setup: SystemSetup,
service_installed: StatusTernary, service_installed: StatusTernary,
socket_installed: StatusTernary, socket_installed: StatusTernary,
@ -12,14 +10,13 @@ pub struct DiagGroup {
} }
pub struct DiagGroupInit { pub struct DiagGroupInit {
pub daemon_client: Arc<Mutex<Option<DaemonClient>>>,
pub system_setup: SystemSetup, pub system_setup: SystemSetup,
} }
impl DiagGroup { impl DiagGroup {
async fn new(daemon_client: Arc<Mutex<Option<DaemonClient>>>) -> Result<Self> { async fn new() -> Result<Self> {
let system_setup = SystemSetup::new(); let system_setup = SystemSetup::new();
let daemon_running = daemon_client.lock().await.is_some(); let daemon_running = daemon_api::daemon_available().await;
Ok(Self { Ok(Self {
service_installed: system_setup.is_service_installed()?, service_installed: system_setup.is_service_installed()?,
@ -27,7 +24,6 @@ impl DiagGroup {
socket_enabled: system_setup.is_socket_enabled()?, socket_enabled: system_setup.is_socket_enabled()?,
daemon_running, daemon_running,
system_setup, system_setup,
daemon_client,
}) })
} }
} }
@ -95,7 +91,8 @@ impl AsyncComponent for DiagGroup {
sender: AsyncComponentSender<Self>, sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> { ) -> AsyncComponentParts<Self> {
// Should be impossible to panic here // Should be impossible to panic here
let model = DiagGroup::new(init.daemon_client).await.unwrap(); let mut model = DiagGroup::new().await.unwrap();
model.system_setup = init.system_setup;
let widgets = view_output!(); let widgets = view_output!();
@ -111,7 +108,7 @@ impl AsyncComponent for DiagGroup {
match msg { match msg {
DiagGroupMsg::Refresh => { DiagGroupMsg::Refresh => {
// Should be impossible to panic here // Should be impossible to panic here
*self = Self::new(Arc::clone(&self.daemon_client)).await.unwrap(); *self = Self::new().await.unwrap();
} }
} }
} }

View file

@ -6,10 +6,6 @@ pub struct SettingsScreen {
daemon_group: AsyncController<settings::DaemonGroup>, daemon_group: AsyncController<settings::DaemonGroup>,
} }
pub struct SettingsScreenInit {
pub daemon_client: Arc<Mutex<Option<DaemonClient>>>,
}
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum SettingsScreenMsg { pub enum SettingsScreenMsg {
DaemonStateChange, DaemonStateChange,
@ -17,7 +13,7 @@ pub enum SettingsScreenMsg {
#[relm4::component(pub)] #[relm4::component(pub)]
impl SimpleComponent for SettingsScreen { impl SimpleComponent for SettingsScreen {
type Init = SettingsScreenInit; type Init = ();
type Input = SettingsScreenMsg; type Input = SettingsScreenMsg;
type Output = (); type Output = ();
@ -27,26 +23,20 @@ impl SimpleComponent for SettingsScreen {
} }
fn init( fn init(
init: Self::Init, _init: Self::Init,
root: &Self::Root, root: &Self::Root,
sender: ComponentSender<Self>, sender: ComponentSender<Self>,
) -> ComponentParts<Self> { ) -> ComponentParts<Self> {
let system_setup = SystemSetup::new(); let system_setup = SystemSetup::new();
let diag_group = settings::DiagGroup::builder() let diag_group = settings::DiagGroup::builder()
.launch(settings::DiagGroupInit { .launch(settings::DiagGroupInit { system_setup })
system_setup,
daemon_client: Arc::clone(&init.daemon_client),
})
.forward(sender.input_sender(), |_| { .forward(sender.input_sender(), |_| {
SettingsScreenMsg::DaemonStateChange SettingsScreenMsg::DaemonStateChange
}); });
let daemon_group = settings::DaemonGroup::builder() let daemon_group = settings::DaemonGroup::builder()
.launch(settings::DaemonGroupInit { .launch(settings::DaemonGroupInit { system_setup })
system_setup,
daemon_client: Arc::clone(&init.daemon_client),
})
.forward(sender.input_sender(), |_| { .forward(sender.input_sender(), |_| {
SettingsScreenMsg::DaemonStateChange SettingsScreenMsg::DaemonStateChange
}); });

View file

@ -1,27 +1,22 @@
use super::*; use super::*;
pub struct SwitchScreen { pub struct SwitchScreen {
daemon_client: Arc<Mutex<Option<DaemonClient>>>,
switch: gtk::Switch, switch: gtk::Switch,
switch_screen: gtk::Box, switch_screen: gtk::Box,
disconnected_banner: adw::Banner, disconnected_banner: adw::Banner,
} status_label: gtk::Label,
pub struct SwitchScreenInit {
pub daemon_client: Arc<Mutex<Option<DaemonClient>>>,
} }
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum SwitchScreenMsg { pub enum SwitchScreenMsg {
DaemonReconnect, Refresh,
DaemonDisconnect,
Start, Start,
Stop, Stop,
} }
#[relm4::component(pub, async)] #[relm4::component(pub, async)]
impl AsyncComponent for SwitchScreen { impl AsyncComponent for SwitchScreen {
type Init = SwitchScreenInit; type Init = ();
type Input = SwitchScreenMsg; type Input = SwitchScreenMsg;
type Output = (); type Output = ();
type CommandOutput = (); type CommandOutput = ();
@ -39,7 +34,7 @@ impl AsyncComponent for SwitchScreen {
#[name(setup_banner)] #[name(setup_banner)]
adw::Banner { adw::Banner {
set_title: "Burrow is not running!", set_title: "Burrow daemon is not reachable",
}, },
}, },
@ -52,7 +47,12 @@ impl AsyncComponent for SwitchScreen {
set_vexpand: true, set_vexpand: true,
gtk::Label { gtk::Label {
set_label: "Burrow Switch", set_label: "Burrow Tunnel",
},
#[name(status_label)]
gtk::Label {
set_label: "Checking daemon status",
}, },
#[name(switch)] #[name(switch)]
@ -68,91 +68,76 @@ impl AsyncComponent for SwitchScreen {
} }
async fn init( async fn init(
init: Self::Init, _: Self::Init,
root: Self::Root, root: Self::Root,
sender: AsyncComponentSender<Self>, sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> { ) -> AsyncComponentParts<Self> {
let mut initial_switch_status = false;
let mut initial_daemon_server_down = false;
if let Some(daemon_client) = init.daemon_client.lock().await.as_mut() {
if let Ok(res) = daemon_client
.send_command(DaemonCommand::ServerInfo)
.await
.as_ref()
{
initial_switch_status = match res.result.as_ref() {
Ok(DaemonResponseData::None) => false,
Ok(DaemonResponseData::ServerInfo(_)) => true,
_ => false,
};
} else {
initial_daemon_server_down = true;
}
} else {
initial_daemon_server_down = true;
}
let widgets = view_output!(); let widgets = view_output!();
widgets.switch.set_active(initial_switch_status); let mut model = SwitchScreen {
if initial_daemon_server_down {
*init.daemon_client.lock().await = None;
widgets.switch.set_active(false);
widgets.switch_screen.set_sensitive(false);
widgets.setup_banner.set_revealed(true);
}
let model = SwitchScreen {
daemon_client: init.daemon_client,
switch: widgets.switch.clone(), switch: widgets.switch.clone(),
switch_screen: widgets.switch_screen.clone(), switch_screen: widgets.switch_screen.clone(),
disconnected_banner: widgets.setup_banner.clone(), disconnected_banner: widgets.setup_banner.clone(),
status_label: widgets.status_label.clone(),
}; };
model.refresh().await;
AsyncComponentParts { model, widgets } AsyncComponentParts { model, widgets }
} }
async fn update( async fn update(
&mut self, &mut self,
msg: Self::Input, msg: Self::Input,
_: AsyncComponentSender<Self>, _sender: AsyncComponentSender<Self>,
_root: &Self::Root, _root: &Self::Root,
) { ) {
let mut disconnected_daemon_client = false;
if let Some(daemon_client) = self.daemon_client.lock().await.as_mut() {
match msg { match msg {
Self::Input::Start => { SwitchScreenMsg::Refresh => self.refresh().await,
if let Err(_e) = daemon_client SwitchScreenMsg::Start => {
.send_command(DaemonCommand::Start(Default::default())) match daemon_api::start_tunnel().await {
.await Ok(()) => self.status_label.set_label("Tunnel running"),
{ Err(error) => self
disconnected_daemon_client = true; .status_label
.set_label(&format!("Start failed: {error}")),
} }
self.refresh().await;
} }
Self::Input::Stop => { SwitchScreenMsg::Stop => {
if let Err(_e) = daemon_client.send_command(DaemonCommand::Stop).await { match daemon_api::stop_tunnel().await {
disconnected_daemon_client = true; Ok(()) => self.status_label.set_label("Tunnel stopped"),
Err(error) => self
.status_label
.set_label(&format!("Stop failed: {error}")),
} }
self.refresh().await;
}
}
}
}
impl SwitchScreen {
async fn refresh(&mut self) {
match daemon_api::tunnel_state().await {
Ok(daemon_api::TunnelState::Running) => {
self.disconnected_banner.set_revealed(false);
self.switch_screen.set_sensitive(true);
self.status_label.set_label("Tunnel running");
self.switch.set_active(true);
}
Ok(daemon_api::TunnelState::Stopped) => {
self.disconnected_banner.set_revealed(false);
self.switch_screen.set_sensitive(true);
self.status_label.set_label("Tunnel stopped");
self.switch.set_active(false);
}
Err(error) => {
self.disconnected_banner.set_revealed(true);
self.switch_screen.set_sensitive(false);
self.status_label
.set_label(&format!("Daemon unavailable: {error}"));
self.switch.set_active(false);
} }
_ => {}
}
} else {
disconnected_daemon_client = true;
}
if msg == Self::Input::DaemonReconnect {
self.disconnected_banner.set_revealed(false);
self.switch_screen.set_sensitive(true);
}
if disconnected_daemon_client || msg == Self::Input::DaemonDisconnect {
*self.daemon_client.lock().await = None;
self.switch.set_active(false);
self.switch_screen.set_sensitive(false);
self.disconnected_banner.set_revealed(true);
} }
} }
} }

View file

@ -35,8 +35,8 @@ use crate::daemon::rpc::{grpc_defs::Empty, BurrowClient};
#[command( #[command(
about = "Burrow is a tool for burrowing through firewalls, built by teenagers at Hack Club.", about = "Burrow is a tool for burrowing through firewalls, built by teenagers at Hack Club.",
long_about = "Burrow is a 🚀 blazingly fast 🚀 tool designed to penetrate unnecessarily restrictive firewalls, providing teenagers worldwide with secure, less-filtered, and safe access to the internet! long_about = "Burrow is a 🚀 blazingly fast 🚀 tool designed to penetrate unnecessarily restrictive firewalls, providing teenagers worldwide with secure, less-filtered, and safe access to the internet!
It's being built by teenagers from Hack Club, in public! Check it out: https://github.com/hackclub/burrow It's being built by teenagers from Hack Club, in public! Check it out: https://git.burrow.net/hackclub/burrow
Spotted a bug? Please open an issue! https://github.com/hackclub/burrow/issues/new" Spotted a bug? Please open an issue on the Burrow forge."
)] )]
struct Cli { struct Cli {

View file

@ -2,6 +2,7 @@
groups = { groups = {
users = "burrow-users"; users = "burrow-users";
admins = "burrow-admins"; admins = "burrow-admins";
automation = "burrow-automation";
linear = { linear = {
owners = "linear-owners"; owners = "linear-owners";
admins = "linear-admins"; admins = "linear-admins";
@ -75,6 +76,30 @@
]; ];
}; };
release = {
displayName = "Burrow Release";
canonicalEmail = "release@burrow.net";
isAdmin = false;
forgeAuthorized = false;
bootstrapAuthentik = false;
roles = [
"automation"
"release"
];
};
namespace = {
displayName = "Burrow Namespace";
canonicalEmail = "namespace@burrow.net";
isAdmin = false;
forgeAuthorized = false;
bootstrapAuthentik = false;
roles = [
"automation"
"namespace-runners"
];
};
ui-test = { ui-test = {
displayName = "Burrow UI Test"; displayName = "Burrow UI Test";
canonicalEmail = "ui-test@burrow.net"; canonicalEmail = "ui-test@burrow.net";

View file

@ -0,0 +1,9 @@
[package]
name = "burrow-core"
version = "0.1.0"
edition = "2021"
description = "Cross-platform Burrow protocol and application core."
license = "GPL-3.0-or-later"
[lib]
crate-type = ["lib", "staticlib"]

View file

@ -0,0 +1,61 @@
#![deny(missing_debug_implementations)]
/// Version of the Rust core linked into platform shells.
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Platform family reported by the current Rust target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlatformFamily {
Android,
Apple,
Linux,
Windows,
Other,
}
impl PlatformFamily {
pub fn current() -> Self {
if cfg!(target_os = "android") {
Self::Android
} else if cfg!(target_vendor = "apple") {
Self::Apple
} else if cfg!(target_os = "linux") {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Other
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Android => "android",
Self::Apple => "apple",
Self::Linux => "linux",
Self::Windows => "windows",
Self::Other => "other",
}
}
}
/// Human-readable core identity for stubs and smoke tests.
pub fn identity() -> String {
format!(
"burrow-core/{} ({})",
version(),
PlatformFamily::current().as_str()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_includes_project_name() {
assert!(identity().starts_with("burrow-core/"));
}
}

View file

@ -0,0 +1,13 @@
[package]
name = "burrow-mobile-ffi"
version = "0.1.0"
edition = "2021"
description = "Mobile FFI boundary for the Burrow Rust core."
license = "GPL-3.0-or-later"
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
burrow-core = { path = "../burrow-core" }
jni = "0.21"

View file

@ -0,0 +1,27 @@
#![deny(missing_debug_implementations)]
use std::ffi::c_char;
use jni::objects::JClass;
use jni::sys::jstring;
use jni::JNIEnv;
static CORE_VERSION: &[u8] = concat!(env!("CARGO_PKG_VERSION"), "\0").as_bytes();
/// C ABI entrypoint for platform shells that do not use JNI.
#[no_mangle]
pub extern "C" fn burrow_core_version() -> *const c_char {
CORE_VERSION.as_ptr().cast()
}
/// JNI entrypoint used by the Android Kotlin stub.
#[no_mangle]
pub extern "system" fn Java_net_burrow_android_BurrowCore_version(
env: JNIEnv<'_>,
_class: JClass<'_>,
) -> jstring {
match env.new_string(burrow_core::identity()) {
Ok(value) => value.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}

Some files were not shown because too many files have changed in this diff Show more