Merge branch 'main' of github.com:bluesky-social/social-app into add-tida-form-url

This commit is contained in:
Chowdhury Foysal Ahamed
2026-07-27 16:50:36 +02:00
419 changed files with 120737 additions and 61103 deletions
+15
View File
@@ -0,0 +1,15 @@
---
name: Compile translations
description: Compile i18n translations and fail on compilation errors.
runs:
using: composite
steps:
- name: 🔤 Compile translations
shell: bash
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
shell: bash
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
@@ -0,0 +1,72 @@
---
name: Local EAS Build
description: Build an Expo app locally with a selected EAS profile.
inputs:
platform:
description: EAS platform to build (ios or android)
required: true
profile:
description: EAS build profile
required: true
output:
description: Output path for the local build artifact
required: true
log-path:
description: Optional path to tee build output into
required: false
default: ""
bump-build-number:
description: Run the build through use-build-number-with-bump
required: false
default: "false"
sentry-auth-token:
description: Optional Sentry authentication token
required: false
default: ""
sentry-release:
description: Optional Sentry release
required: false
default: ""
sentry-dist:
description: Optional Sentry distribution
required: false
default: ""
runs:
using: composite
steps:
- name: Build locally with EAS
shell: bash
env:
PLATFORM: ${{ inputs.platform }}
PROFILE: ${{ inputs.profile }}
OUTPUT: ${{ inputs.output }}
LOG_PATH: ${{ inputs.log-path }}
BUMP_BUILD_NUMBER: ${{ inputs.bump-build-number }}
SENTRY_AUTH_TOKEN: ${{ inputs.sentry-auth-token }}
SENTRY_RELEASE: ${{ inputs.sentry-release }}
SENTRY_DIST: ${{ inputs.sentry-dist }}
run: |
set -o pipefail
build_command=(
pnpm eas build
--platform "$PLATFORM"
--profile "$PROFILE"
--local
--output "$OUTPUT"
--non-interactive
)
if [ -n "$LOG_PATH" ]; then
mkdir -p "$(dirname "$LOG_PATH")"
if [ "$BUMP_BUILD_NUMBER" = "true" ]; then
pnpm use-build-number-with-bump "${build_command[@]}" 2>&1 | tee "$LOG_PATH"
else
"${build_command[@]}" 2>&1 | tee "$LOG_PATH"
fi
elif [ "$BUMP_BUILD_NUMBER" = "true" ]; then
pnpm use-build-number-with-bump "${build_command[@]}"
else
"${build_command[@]}"
fi
+50
View File
@@ -0,0 +1,50 @@
---
name: Setup denis CLI
description: >
Download and verify the denis OTA publish binary from the (private)
bluesky-social/tango releases and put it on PATH. Uses a short-lived GitHub
App token scoped to contents:read on tango, since the default GITHUB_TOKEN
cannot read a private repo's releases.
inputs:
release-tag:
description: denis release tag in bluesky-social/tango to download
required: true
app-id:
description: GitHub App ID for the token used to read tango releases
required: true
private-key:
description: GitHub App private key
required: true
runs:
using: composite
steps:
- name: 🔑 Mint tango read token
id: tango-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ inputs.private-key }}
repositories: tango
permission-contents: read
- name: ⬇️ Download and verify denis binary
shell: bash
env:
GH_TOKEN: ${{ steps.tango-token.outputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
run: |
BIN_DIR="$RUNNER_TEMP/denis-bin"
mkdir -p "$BIN_DIR"
cd "$BIN_DIR"
gh release download "$RELEASE_TAG" \
--repo bluesky-social/tango \
--pattern denis-linux-amd64 \
--pattern denis-linux-amd64.sha256 \
--clobber
# Verify before making it executable / putting it on PATH.
sha256sum -c denis-linux-amd64.sha256
mv denis-linux-amd64 denis
chmod +x denis
echo "$BIN_DIR" >> "$GITHUB_PATH"
@@ -0,0 +1,47 @@
---
name: Setup Expo Project
description: Install dependencies and set up the Expo/EAS CLI for a build. Does not check out the repo.
inputs:
expo-token:
description: Expo token (EXPO_TOKEN secret)
required: true
eas-version:
description: EAS CLI version to install
required: false
default: '19.0.5'
runs:
using: composite
steps:
- name: Check for EXPO_TOKEN
shell: bash
env:
EXPO_TOKEN: ${{ inputs.expo-token }}
run: >
if [ -z "$EXPO_TOKEN" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
- name: ⚙️ Install dependencies
shell: bash
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: ${{ inputs.eas-version }}
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ inputs.expo-token }}
+63
View File
@@ -0,0 +1,63 @@
---
name: Write Environment Variables
description: Write the .env file and google-services.json used by the build.
inputs:
env-token:
description: Base .env contents (ENV_TOKEN secret)
required: true
sentry-dsn:
description: Sentry DSN (SENTRY_DSN secret)
required: true
bitdrift-api-key:
description: Bitdrift API key (BITDRIFT_API_KEY secret)
required: true
gcp-project-id:
description: GCP project ID (EXPO_PUBLIC_GCP_PROJECT_ID secret)
required: true
google-services-token:
description: google-services.json contents (GOOGLE_SERVICES_TOKEN secret)
required: true
expo-public-env:
description: >
EXPO_PUBLIC_ENV value. Only set for OTA deploys where eas.json isn't used;
for regular builds this is normally handled in eas.json.
required: false
default: ''
outputs:
release-version:
description: Version from package.json
value: ${{ steps.env.outputs.release-version }}
bundle-identifier:
description: git SHA of HEAD
value: ${{ steps.env.outputs.bundle-identifier }}
runs:
using: composite
steps:
- name: ✏️ Write environment variables
id: env
shell: bash
env:
ENV_TOKEN: ${{ inputs.env-token }}
SENTRY_DSN: ${{ inputs.sentry-dsn }}
BITDRIFT_API_KEY: ${{ inputs.bitdrift-api-key }}
GCP_PROJECT_ID: ${{ inputs.gcp-project-id }}
GOOGLE_SERVICES_TOKEN: ${{ inputs.google-services-token }}
EXPO_PUBLIC_ENV: ${{ inputs.expo-public-env }}
run: |
echo "$ENV_TOKEN" > .env
# EXPO_PUBLIC_ENV is normally handled in eas.json; only written here for OTA deploys.
if [ -n "$EXPO_PUBLIC_ENV" ]; then
echo "EXPO_PUBLIC_ENV=$EXPO_PUBLIC_ENV" >> .env
fi
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "release-version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "bundle-identifier=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=$SENTRY_DSN" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=$BITDRIFT_API_KEY" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=$GCP_PROJECT_ID" >> .env
echo "$GOOGLE_SERVICES_TOKEN" > google-services.json
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set +e
platform="${1:?usage: cleanup-nightly-e2e.sh <ios|android> <device-id>}"
device_id="${2:-}"
artifact_dir="${GITHUB_WORKSPACE:-$PWD}/artifacts/$platform"
mkdir -p "$artifact_dir"
if [[ -f i18n.log ]]; then
cp i18n.log "$artifact_dir/i18n.log"
fi
stop_process_tree() {
local pid="$1"
local child
while read -r child; do
[[ -n "$child" ]] && stop_process_tree "$child"
done < <(pgrep -P "$pid" 2>/dev/null || true)
kill -TERM "$pid" >/dev/null 2>&1 || true
}
stop_pid_file() {
[[ -f "$1" ]] || return 0
local pid
pid="$(cat "$1")"
stop_process_tree "$pid"
}
stop_pid_file "$artifact_dir/logcat.pid"
stop_pid_file "$artifact_dir/metro.pid"
stop_pid_file "$artifact_dir/mock-server.pid"
stop_pid_file "$artifact_dir/emulator.pid"
if [[ "$platform" == "ios" ]]; then
if [[ -f "$artifact_dir/redis-bin.txt" ]]; then
"$(cat "$artifact_dir/redis-bin.txt")/redis-cli" \
-h 127.0.0.1 -p 6380 shutdown nosave >/dev/null 2>&1 || true
fi
if [[ -f "$artifact_dir/postgres-bin.txt" ]]; then
"$(cat "$artifact_dir/postgres-bin.txt")/pg_ctl" \
-D "${RUNNER_TEMP:-/tmp}/nightly-e2e-postgres" -m fast stop >/dev/null 2>&1 || true
fi
[[ -n "$device_id" ]] && xcrun simctl shutdown "$device_id" >/dev/null 2>&1 || true
else
docker compose -f dev-env/dev-infra/docker-compose.yaml logs --no-color \
>>"$artifact_dir/docker-services.log" 2>&1 || true
docker compose -f dev-env/dev-infra/docker-compose.yaml down --volumes --remove-orphans >/dev/null 2>&1 || true
[[ -n "$device_id" ]] && adb -s "$device_id" emu kill >/dev/null 2>&1 || true
fi
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -Eeuo pipefail
platform="${1:?usage: run-nightly-e2e.sh <ios|android> <device-id>}"
device_id="${2:?usage: run-nightly-e2e.sh <ios|android> <device-id>}"
if [[ "$platform" != "ios" && "$platform" != "android" ]]; then
echo "Unsupported platform: $platform" >&2
exit 2
fi
artifact_dir="${GITHUB_WORKSPACE:-$PWD}/artifacts/$platform"
maestro_dir="$artifact_dir/maestro"
mkdir -p "$maestro_dir"
phase() {
printf '%s\n' "$1" >"$artifact_dir/phase.txt"
}
wait_for_port() {
local port="$1"
local label="$2"
local attempts="${3:-120}"
for ((i = 1; i <= attempts; i++)); do
if nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
echo "Timed out waiting for $label on port $port" >&2
return 1
}
# shellcheck disable=SC2329 # Invoked through the cleanup trap call chain.
stop_process_tree() {
local pid="$1"
local child
while read -r child; do
[[ -n "$child" ]] && stop_process_tree "$child"
done < <(pgrep -P "$pid" 2>/dev/null || true)
kill -TERM "$pid" >/dev/null 2>&1 || true
}
# shellcheck disable=SC2329 # Invoked by cleanup, which is registered as a trap.
stop_pid_file() {
local pid_file="$1"
[[ -f "$pid_file" ]] || return 0
local pid
pid="$(cat "$pid_file")"
[[ -n "$pid" ]] || return 0
# pnpm and Expo both spawn multiple generations of children.
stop_process_tree "$pid"
}
# shellcheck disable=SC2329 # Invoked by the EXIT/INT/TERM trap below.
cleanup() {
set +e
stop_pid_file "$artifact_dir/logcat.pid"
stop_pid_file "$artifact_dir/metro.pid"
stop_pid_file "$artifact_dir/mock-server.pid"
if [[ "$platform" == "ios" ]]; then
if [[ -f "$artifact_dir/redis.pid" ]]; then
redis_bin="$(cat "$artifact_dir/redis-bin.txt")"
"$redis_bin/redis-cli" -h 127.0.0.1 -p 6380 shutdown nosave >/dev/null 2>&1 || true
fi
if [[ -f "$artifact_dir/postgres-bin.txt" && -d "${RUNNER_TEMP:-/tmp}/nightly-e2e-postgres" ]]; then
postgres_bin="$(cat "$artifact_dir/postgres-bin.txt")"
"$postgres_bin/pg_ctl" -D "${RUNNER_TEMP:-/tmp}/nightly-e2e-postgres" -m fast stop >/dev/null 2>&1 || true
fi
else
docker compose -f dev-env/dev-infra/docker-compose.yaml logs --no-color \
>>"$artifact_dir/docker-services.log" 2>&1 || true
docker compose -f dev-env/dev-infra/docker-compose.yaml down --volumes --remove-orphans >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT INT TERM
if [[ "$platform" == "android" ]]; then
adb -s "$device_id" logcat -c
adb -s "$device_id" logcat -v threadtime >"$artifact_dir/logcat.log" 2>&1 &
printf '%s\n' "$!" >"$artifact_dir/logcat.pid"
fi
phase "Starting PostgreSQL, Redis, and mock server"
if [[ "$platform" == "ios" ]]; then
brew install postgresql@14 2>&1 | tee "$artifact_dir/native-dependencies.log"
postgres_bin="$(brew --prefix postgresql@14)/bin"
redis_version="7.4.7"
redis_archive="${RUNNER_TEMP:-/tmp}/redis-${redis_version}.tar.gz"
redis_source="${RUNNER_TEMP:-/tmp}/redis-${redis_version}"
curl -fsSL -o "$redis_archive" \
"https://download.redis.io/releases/redis-${redis_version}.tar.gz"
echo "c97e57b0df330a9e091cacff012bebe763c275398cf36ff44cdba876814b595b $redis_archive" \
| shasum -a 256 --check | tee -a "$artifact_dir/native-dependencies.log"
rm -rf "$redis_source"
tar -xzf "$redis_archive" -C "${RUNNER_TEMP:-/tmp}"
make -C "$redis_source" -j "$(sysctl -n hw.ncpu)" \
2>&1 | tee -a "$artifact_dir/native-dependencies.log"
redis_bin="$redis_source/src"
"$redis_bin/redis-server" --version | tee -a "$artifact_dir/native-dependencies.log"
printf '%s\n' "$redis_bin" >"$artifact_dir/redis-bin.txt"
postgres_data="${RUNNER_TEMP:-/tmp}/nightly-e2e-postgres"
rm -rf "$postgres_data"
"$postgres_bin/initdb" -D "$postgres_data" --auth=trust --username=pg --no-locale \
>"$artifact_dir/postgres-init.log" 2>&1
"$postgres_bin/pg_ctl" -D "$postgres_data" \
-o "-p 5433 -h 127.0.0.1" -l "$artifact_dir/postgres.log" start
printf '%s\n' "$postgres_bin" >"$artifact_dir/postgres-bin.txt"
"$redis_bin/redis-server" \
--bind 127.0.0.1 \
--port 6380 \
--save "" \
--appendonly no \
--daemonize yes \
--pidfile "$artifact_dir/redis.pid" \
--logfile "$artifact_dir/redis.log"
wait_for_port 5433 "PostgreSQL"
wait_for_port 6380 "Redis"
pnpm --dir dev-env start:external >"$artifact_dir/mock-server.log" 2>&1 &
else
pnpm --dir dev-env start >"$artifact_dir/mock-server.log" 2>&1 &
fi
printf '%s\n' "$!" >"$artifact_dir/mock-server.pid"
wait_for_port 1986 "the E2E mock-server manager"
phase "Starting Metro"
EXPO_PUBLIC_ENV=e2e \
NODE_ENV=development \
RN_SRC_EXT=e2e.ts,e2e.tsx \
pnpm exec expo start --dev-client --clear --port 8081 \
>"$artifact_dir/metro.log" 2>&1 &
printf '%s\n' "$!" >"$artifact_dir/metro.pid"
wait_for_port 8081 "Metro"
# Pre-warm Metro bundle so the first Maestro flow doesn't hit a cold-start delay
phase "Pre-warming Metro bundle"
bundle_platform="$platform"
curl -s -o /dev/null "http://localhost:8081/index.bundle?platform=${bundle_platform}&dev=true&minify=false"
echo "Metro bundle pre-warmed for $bundle_platform"
if [[ "$platform" == "android" ]]; then
phase "Configuring Android localhost routing"
adb -s "$device_id" reverse tcp:3000 tcp:3000
adb -s "$device_id" reverse tcp:8081 tcp:8081
fi
phase "Running Maestro flows"
set +e
maestro test \
--udid "$device_id" \
--format JUNIT \
--output "$artifact_dir/report.xml" \
--config __e2e__/config.yml \
--debug-output "$maestro_dir" \
--test-output-dir "$maestro_dir" \
--flatten-debug-output \
__e2e__ \
2>&1 | tee "$artifact_dir/maestro-cli.log"
maestro_status=${PIPESTATUS[0]}
set -e
if [[ "$maestro_status" -eq 0 ]]; then
phase "Completed"
else
phase "Maestro flow failure"
fi
exit "$maestro_status"
+356
View File
@@ -0,0 +1,356 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const ENTITY_REPLACEMENTS = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
function decodeXml(value = '') {
return value
.replace(/&(amp|apos|gt|lt|quot);/g, entity => ENTITY_REPLACEMENTS[entity])
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([\da-f]+);/gi, (_, code) =>
String.fromCodePoint(Number.parseInt(code, 16)),
)
}
function attributes(source = '') {
const result = {}
for (const match of source.matchAll(/([\w:.-]+)\s*=\s*(["'])(.*?)\2/gs)) {
result[match[1]] = decodeXml(match[3])
}
return result
}
function concise(value, limit = 300) {
const normalized = decodeXml(value)
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return normalized.length > limit
? `${normalized.slice(0, limit - 1)}`
: normalized
}
export function parseJUnit(xml) {
const failures = []
const testcasePattern = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/gi
for (const match of xml.matchAll(testcasePattern)) {
const testcase = attributes(match[1])
const body = match[2] || ''
const failure = body.match(/<(failure|error)\b([^>]*)>([\s\S]*?)<\/\1>/i)
const selfClosingFailure = body.match(/<(failure|error)\b([^>]*)\/>/i)
const failureMatch = failure || selfClosingFailure
if (!failureMatch) continue
const failureAttributes = attributes(failureMatch[2])
const name = testcase.name || testcase.classname || 'Unnamed Maestro flow'
const message = concise(
failureAttributes.message || (failure ? failureMatch[3] : '') || 'Failed',
)
failures.push({name, message})
}
if (failures.length === 0) {
const suite = xml.match(/<testsuite\b([^>]*)>/i)
const suiteAttributes = attributes(suite?.[1])
if (
Number(suiteAttributes.failures || 0) +
Number(suiteAttributes.errors || 0) >
0
) {
failures.push({
name: suiteAttributes.name || 'Maestro test suite',
message: 'JUnit reported a failure without testcase details',
})
}
}
return failures
}
export function parseMaestroCli(log) {
const failures = []
const failurePattern = /^\[Failed\]\s+(.+?)\s+\([^)]*\)\s+\((.+)\)\s*$/gm
for (const match of log.matchAll(failurePattern)) {
failures.push({
name: concise(match[1], 120),
message: concise(match[2]),
})
}
return failures
}
function walk(root) {
if (!root || !fs.existsSync(root)) return []
const entries = fs.readdirSync(root, {withFileTypes: true})
return entries.flatMap(entry => {
const candidate = path.join(root, entry.name)
return entry.isDirectory() ? walk(candidate) : [candidate]
})
}
function readPhase(root) {
const phaseFile = walk(root).find(file => path.basename(file) === 'phase.txt')
return phaseFile ? fs.readFileSync(phaseFile, 'utf8').trim() : ''
}
function platformResult({name, status, root, artifactUrl}) {
const files = walk(root)
const reports = files.filter(file => /(?:report|junit).*\.xml$/i.test(file))
const junitFailures = reports.flatMap(report =>
parseJUnit(fs.readFileSync(report, 'utf8')),
)
const maestroLogs = files.filter(
file => path.basename(file) === 'maestro-cli.log',
)
const cliFailures = maestroLogs.flatMap(log =>
parseMaestroCli(fs.readFileSync(log, 'utf8')),
)
// A cancelled or timed-out Maestro run may never flush JUnit. Its CLI log is
// streamed continuously, so use those failure lines when JUnit has no detail.
const failures = junitFailures.length > 0 ? junitFailures : cliFailures
// A skipped platform (e.g. iOS while temporarily disabled) is not a failure
// as long as it produced no flow failures.
const failed =
(status !== 'success' && status !== 'skipped') || failures.length > 0
return {
name,
status,
failed,
failures,
phase: readPhase(root),
hasJUnit: reports.length > 0,
artifactUrl,
}
}
function statusEmoji(status) {
if (status === 'success') return ':white_check_mark:'
if (status === 'skipped') return ':fast_forward:'
return ':x:'
}
function slackEscape(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
function platformBlock(platform) {
const lines = [
`${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``,
]
if (platform.failures.length > 0) {
for (const failure of platform.failures.slice(0, 8)) {
lines.push(
`• *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`,
)
}
if (platform.failures.length > 8) {
lines.push(`• …and ${platform.failures.length - 8} more failed flows`)
}
} else if (platform.failed && !platform.hasJUnit) {
lines.push(
`• *Setup phase:* ${slackEscape(platform.phase || 'No phase metadata was captured')}`,
)
} else if (platform.failed) {
lines.push(
`• Job failed after JUnit was written; latest phase: ${slackEscape(platform.phase || 'unknown')}`,
)
}
if (platform.artifactUrl) {
lines.push(
`• <${platform.artifactUrl}|Open ${platform.name} logs and artifacts>`,
)
}
return lines.join('\n').slice(0, 3000)
}
function githubSummary({notify, platforms, shortSha, runUrl, commitUrl}) {
const lines = [
`# Nightly Maestro E2E ${notify ? 'failed' : 'passed'}`,
'',
`- Commit: [\`${shortSha}\`](${commitUrl})`,
`- Workflow run: [open run](${runUrl})`,
'',
]
for (const platform of platforms) {
const headerEmoji =
platform.status === 'success'
? '✅'
: platform.status === 'skipped'
? '⏭️'
: '❌'
lines.push(
`## ${headerEmoji} ${platform.name}`,
'',
`Job status: \`${platform.status}\``,
'',
)
if (platform.failures.length > 0) {
for (const failure of platform.failures.slice(0, 10)) {
lines.push(`- **${failure.name}:** ${failure.message}`)
}
if (platform.failures.length > 10) {
lines.push(`- …and ${platform.failures.length - 10} more failed flows`)
}
lines.push('')
} else if (platform.failed && !platform.hasJUnit) {
lines.push(
`- Setup phase: ${platform.phase || 'No phase metadata was captured'}`,
'',
)
} else if (platform.failed) {
lines.push(
`- The job failed after JUnit was written (latest phase: ${platform.phase || 'unknown'})`,
'',
)
}
if (platform.artifactUrl) {
lines.push(
`- [${platform.name} logs and artifacts](${platform.artifactUrl})`,
'',
)
}
}
return lines.join('\n').trim()
}
export function buildSummary({
iosStatus,
androidStatus,
iosRoot,
androidRoot,
artifactUrls = {},
sha,
runUrl,
commitUrl,
}) {
const platforms = [
platformResult({
name: 'iOS',
status: iosStatus,
root: iosRoot,
artifactUrl: artifactUrls.ios,
}),
platformResult({
name: 'Android',
status: androidStatus,
root: androidRoot,
artifactUrl: artifactUrls.android,
}),
]
const notify = platforms.some(platform => platform.failed)
const shortSha = sha.slice(0, 12)
const lines = [
':rotating_light: *Nightly Maestro E2E failed*',
`*Commit:* <${commitUrl}|\`${shortSha}\`>`,
`*Workflow run:* <${runUrl}|open run>`,
'',
]
for (const platform of platforms) {
lines.push(
`${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``,
)
if (platform.failures.length > 0) {
for (const failure of platform.failures.slice(0, 10)) {
lines.push(
`• *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`,
)
}
if (platform.failures.length > 10) {
lines.push(`• …and ${platform.failures.length - 10} more failed flows`)
}
} else if (platform.failed && !platform.hasJUnit) {
lines.push(
`• Setup phase: ${platform.phase || 'No phase metadata was captured'}`,
)
} else if (platform.failed) {
lines.push(
`• The job failed after JUnit was written (latest phase: ${platform.phase || 'unknown'})`,
)
}
if (platform.artifactUrl) {
lines.push(
`• <${platform.artifactUrl}|${platform.name} logs and artifacts>`,
)
}
lines.push('')
}
const text = lines.join('\n').trim()
const blocks = [
{
type: 'header',
text: {type: 'plain_text', text: 'Nightly Maestro E2E failed'},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Commit:* <${commitUrl}|\`${shortSha}\`>\n*Workflow run:* <${runUrl}|open run>`,
},
},
{type: 'divider'},
...platforms.flatMap((platform, index) => [
{type: 'section', text: {type: 'mrkdwn', text: platformBlock(platform)}},
...(index < platforms.length - 1 ? [{type: 'divider'}] : []),
]),
]
return {
notify,
platforms,
githubSummary: githubSummary({
notify,
platforms,
shortSha,
runUrl,
commitUrl,
}),
payload: {text, blocks},
}
}
function parseArgs(argv) {
const result = {}
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i]
if (!key?.startsWith('--') || argv[i + 1] === undefined) {
throw new Error(`Invalid argument: ${key || '<missing>'}`)
}
result[key.slice(2)] = argv[i + 1]
}
return result
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
) {
const args = parseArgs(process.argv.slice(2))
const artifactUrls = args['artifact-urls']
? JSON.parse(fs.readFileSync(args['artifact-urls'], 'utf8'))
: {}
const summary = buildSummary({
iosStatus: args['ios-status'],
androidStatus: args['android-status'],
iosRoot: args['ios-root'],
androidRoot: args['android-root'],
artifactUrls,
sha: args.sha,
runUrl: args['run-url'],
commitUrl: args['commit-url'],
})
process.stdout.write(`${JSON.stringify(summary)}\n`)
}
@@ -21,20 +21,20 @@ jobs:
id-token: write
steps:
- name: Checkout repository
- name: ⬇️ Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
- name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
- name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
- name: 🏷️ Extract Docker metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
@@ -43,13 +43,13 @@ jobs:
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
- name: Env
- name: 📝 Env
id: env
run: |
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Build and push Docker image
- name: 🚀 Build and push Docker image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
@@ -22,20 +22,20 @@ jobs:
id-token: write
steps:
- name: Checkout repository
- name: ⬇️ Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
- name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
- name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME }}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
- name: 🏷️ Extract Docker metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
@@ -44,7 +44,7 @@ jobs:
tags: |
type=sha,enable=true,priority=100,prefix=bskyweb:,suffix=,format=long
- name: Build and push Docker image
- name: 🚀 Build and push Docker image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
@@ -21,20 +21,20 @@ jobs:
id-token: write
steps:
- name: Checkout repository
- name: ⬇️ Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
- name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
- name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
- name: 🏷️ Extract Docker metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
@@ -43,7 +43,7 @@ jobs:
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
- name: Build and push Docker image
- name: 🚀 Build and push Docker image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
@@ -21,20 +21,20 @@ jobs:
id-token: write
steps:
- name: Checkout repository
- name: ⬇️ Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
- name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
- name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
- name: 🏷️ Extract Docker metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
@@ -43,7 +43,7 @@ jobs:
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
- name: Build and push Docker image
- name: 🚀 Build and push Docker image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
@@ -21,20 +21,20 @@ jobs:
id-token: write
steps:
- name: Checkout repository
- name: ⬇️ Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx
- name: 🔧 Setup Docker buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log into registry ${{ env.REGISTRY }}
- name: 🔑 Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME}}
password: ${{ env.PASSWORD }}
- name: Extract Docker metadata
- name: 🏷️ Extract Docker metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
@@ -43,7 +43,7 @@ jobs:
tags: |
type=sha,enable=true,priority=100,prefix=,suffix=,format=long
- name: Build and push Docker image
- name: 🚀 Build and push Docker image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
+108 -84
View File
@@ -10,12 +10,25 @@ on:
options:
- testflight-android
- production
submit:
type: boolean
description: Submit the build to Google Play (disable to only produce the APK artifact)
default: true
workflow_call:
inputs:
profile:
type: string
description: Build profile to use
required: true
submit:
type: boolean
description: Submit the build to Google Play (disable to only produce the APK artifact)
default: true
runner:
type: string
description: Runner for the build job (defaults to Linux-x64-32core)
required: false
default: ''
outputs:
package-version:
description: Version from package.json
@@ -56,93 +69,92 @@ permissions:
jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: Linux-x64-32core
name: Build Android
runs-on: ${{ inputs.runner || 'Linux-x64-32core' }}
concurrency:
group: android-build
cancel-in-progress: false
outputs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
node-version-file: package.json
cache: pnpm
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: "temurin"
java-version: "17"
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
uses: ./.github/actions/compile-i18n
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
- name: ✏️ Write environment variables
id: env
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "$json" > google-services.json
uses: ./.github/actions/write-env
with:
env-token: ${{ secrets.ENV_TOKEN }}
sentry-dsn: ${{ secrets.SENTRY_DSN }}
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight-android' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile $PROFILE
--local --output build.aab --non-interactive
uses: ./.github/actions/eas-local-build
with:
platform: android
profile: ${{ inputs.profile || 'testflight-android' }}
output: build.aab
bump-build-number: "true"
sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry-release: ${{ steps.env.outputs.release-version }}
sentry-dist: ${{ steps.env.outputs.bundle-identifier }}
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
# Hands the built bundle off to the submit / universalApk jobs. Retention is
# deliberately short (1 day) since it's only an intra-run handoff artifact.
- name: 🚀 Upload AAB artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-aab-${{ github.run_id }}
retention-days: 1
if-no-files-found: error
path: build.aab
submit:
name: Submit to Google Play
runs-on: ubuntu-latest
needs: [build]
# Submit unless explicitly disabled; on events where inputs is empty this still submits.
if: ${{ inputs.submit != false }}
steps:
# eas submit reads app config from the repo, so we need a checkout.
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ⬇️ Download AAB artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-aab-${{ github.run_id }}
- name: 🚀 Submit to Google Play
env:
PROFILE: ${{ inputs.profile || 'testflight-android' }}
@@ -150,13 +162,33 @@ jobs:
- name: 🔔 Notify Slack of Play Store Submission
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"}
# Runs in parallel with submit: the QA APK shouldn't be blocked by a Play submission failure.
universalApk:
name: Build universal APK
runs-on: ubuntu-latest
needs: [build]
outputs:
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
steps:
- name: ⬇️ Download AAB artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-aab-${{ github.run_id }}
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
# like the build job so the toolchain is deterministic.
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: "temurin"
java-version: "17"
- name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
@@ -164,19 +196,24 @@ jobs:
version: "1.18.3"
- name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
keystore.jks
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > keystore.jks
- name: 📦 Build signed universal APK
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
bundletool build-apks \
--bundle=build.aab \
--output=universal.apks \
--mode=universal \
--ks=keystore.jks \
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
--ks-pass=pass:"$ANDROID_KEYSTORE_PASSWORD" \
--ks-key-alias="$ANDROID_KEY_ALIAS" \
--key-pass=pass:"$ANDROID_KEY_PASSWORD"
- name: 📋 Rename to .zip for extraction
run: mv universal.apks universal.zip
@@ -198,27 +235,13 @@ jobs:
path: build.apk
- name: 🔔 Notify Slack of APK Artifact
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.profile == 'testflight-android' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.profile == 'testflight-android' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"}
# Releases are cut from tags named after the version (e.g. "1.124.0"), so when a production
# build is dispatched against such a tag we attach the APK to the matching release. This runs
@@ -226,7 +249,7 @@ jobs:
attachToRelease:
name: Attach APK to GitHub Release
runs-on: ubuntu-latest
needs: [build]
needs: [build, universalApk]
if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }}
permissions:
contents: write
@@ -254,7 +277,7 @@ jobs:
if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.build.outputs.apk-artifact-name }}
name: ${{ needs.universalApk.outputs.apk-artifact-name }}
- name: 🏷️ Rename APK for release
if: ${{ steps.release-check.outputs.exists == 'true' }}
@@ -265,6 +288,7 @@ jobs:
if: ${{ steps.release-check.outputs.exists == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
APK: Bluesky-${{ needs.build.outputs.package-version }}.apk
run: |
@@ -274,7 +298,7 @@ jobs:
- name: 🔔 Notify Slack of Release Attachment
if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
+96 -92
View File
@@ -28,6 +28,11 @@ on:
type: string
description: TestFlight group to assign the build to after submitting ("none" to skip)
default: none
runner:
type: string
description: Runner for the build job (defaults to macos-26-xlarge)
required: false
default: ''
outputs:
package-version:
description: Version from package.json
@@ -66,8 +71,8 @@ permissions:
jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-26-xlarge
name: Build iOS
runs-on: ${{ inputs.runner || 'macos-26-xlarge' }}
concurrency:
group: ios-build
cancel-in-progress: false
@@ -75,38 +80,15 @@ jobs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
build-number: ${{ steps.ipa-build-number.outputs.build-number }}
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
node-version-file: package.json
cache: pnpm
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
expo-token: ${{ secrets.EXPO_TOKEN }}
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
@@ -114,7 +96,7 @@ jobs:
- name: ☕️ Assert Cocoapods version
run: |
EXPECTED=1.16.2
EXPECTED=1.17.0
ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
@@ -133,38 +115,29 @@ jobs:
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
uses: ./.github/actions/compile-i18n
# EXPO_PUBLIC_ENV is handled in eas.json
- name: ✏️ Write environment variables
id: env
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
uses: ./.github/actions/write-env
with:
env-token: ${{ secrets.ENV_TOKEN }}
sentry-dsn: ${{ secrets.SENTRY_DSN }}
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p ios
--profile $PROFILE
--local --output build.tar.gz --non-interactive
uses: ./.github/actions/eas-local-build
with:
platform: ios
profile: ${{ inputs.profile || 'testflight' }}
output: build.tar.gz
bump-build-number: "true"
sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry-release: ${{ steps.env.outputs.release-version }}
sentry-dist: ${{ steps.env.outputs.bundle-identifier }}
- name: 📂 Extract build artifact
run: |
@@ -201,16 +174,6 @@ jobs:
exit 1
fi
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
@@ -220,6 +183,7 @@ jobs:
# number that actually lands in App Store Connect. `eas build:version:get` reads the
# remote counter, which a --local build does not advance, so it can be off by one —
# using it here would make distribute_only poll for a nonexistent build.
# PlistBuddy is macOS-only, which is why this stays in the build job.
- name: 🔢 Read build number from IPA
id: ipa-build-number
run: |
@@ -235,18 +199,82 @@ jobs:
echo "IPA build number: $build_number"
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
# Hand the IPA and dSYM off to the submit job. Retention is deliberately short since
# this artifact only exists to bridge the two jobs within a single run.
- name: 🚀 Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-build-${{ github.run_id }}
retention-days: 1
if-no-files-found: error
path: |
${{ env.BUILD_DIR }}/Bluesky.ipa
${{ env.BUILD_DIR }}/Bluesky.app.dSYM.zip
submit:
name: Submit iOS
# Submission and dSYM upload are I/O bound and don't need the xlarge builder.
runs-on: macos-26
needs: [build]
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# eas submit reads the app config from the repo
fetch-depth: 5
- name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ⬇️ Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ios-build-${{ github.run_id }}
path: ios-build
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path ios-build/Bluesky.ipa
- name: 🪲 Upload dSYM to Sentry
env:
SENTRY_ORG: blueskyweb
SENTRY_PROJECT: app
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm sentry-cli debug-files upload ios-build/Bluesky.app.dSYM.zip --include-sources
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.build-number }}```"}
distribute:
name: Assign build to TestFlight group
# fastlane and jq ship preinstalled on the macOS runner image, and this step mostly idles
# polling Apple processing, so it runs on a normal-size runner.
runs-on: macos-26
needs: [build, submit]
# testFlightGroup defaults to 'none' on both workflow_call and dispatch; guard against the
# empty string too, since `!= 'none'` alone would be true for ''.
if: ${{ inputs.testFlightGroup && inputs.testFlightGroup != 'none' }}
steps:
# eas submit only uploads to App Store Connect; it can't assign a build to a
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
# already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group
if: ${{ inputs.testFlightGroup != 'none' }}
env:
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
BUILD_NUMBER: ${{ steps.ipa-build-number.outputs.build-number }}
APP_VERSION: ${{ needs.build.outputs.package-version }}
BUILD_NUMBER: ${{ needs.build.outputs.build-number }}
run: |
# Ensure the API key material is removed even if fastlane exits non-zero
# (the step runs under `bash -e`, which would otherwise abort before cleanup).
@@ -271,27 +299,3 @@ jobs:
build_number:"$BUILD_NUMBER" \
groups:"$TESTFLIGHT_GROUP" \
notify_external_testers:true
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.ipa-build-number.outputs.build-number }}```"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.profile == 'testflight' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
+213 -328
View File
@@ -22,11 +22,23 @@ on:
permissions:
contents: read
# denis release tag in bluesky-social/tango whose linux-amd64 binary this
# workflow downloads to publish OTA bundles. Bump this one line to roll denis.
env:
DENIS_RELEASE_TAG: denis-v0.1.1
jobs:
bundleDeploy:
if: github.repository == 'bluesky-social/social-app'
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
# id-token: write lets this job mint an OIDC token to assume the denis
# publish role; actions: read loads the fingerprint baseline artifact;
# contents: read is still needed for the checkout.
permissions:
id-token: write
actions: read
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
cancel-in-progress: true
@@ -36,7 +48,7 @@ jobs:
steps.version.outputs.version-changed }}
steps:
- name: Check for EXPO_TOKEN
- name: 🔑 Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
@@ -86,32 +98,74 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
- name: ⬇️ Load fingerprint baseline
id: baseline
if: ${{ (inputs.channel || 'testflight') == 'testflight' }}
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY_ID: ${{ github.repository_id }}
run: |
url=$(gh api \
"repos/${GITHUB_REPOSITORY}/actions/artifacts?name=testflight-native-fingerprint&per_page=100" \
--jq "[.artifacts[] | select(
.expired == false and
.workflow_run.head_branch == \"main\" and
.workflow_run.head_repository_id == (\$ENV.REPOSITORY_ID | tonumber)
)] | max_by(.created_at) | .archive_download_url" \
2>/dev/null || true)
if [ -n "$url" ] && [ "$url" != "null" ]; then
mkdir baseline-artifact
if curl -sSL -H "Authorization: Bearer $GH_TOKEN" -o baseline.zip "$url" \
&& unzip -q baseline.zip -d baseline-artifact; then
if jq -e '.sources | type == "array"' \
baseline-artifact/native-fingerprint.json >/dev/null; then
echo "path=baseline-artifact/native-fingerprint.json" >> "$GITHUB_OUTPUT"
else
echo "::warning::Ignoring invalid fingerprint baseline artifact."
fi
else
echo "::warning::Could not download fingerprint baseline artifact."
fi
fi
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0
uses: bluesky-social/github-actions/fingerprint-native@abc6a46eb4badf243f55bfd7d6cec42722456300 # v0.3.0
with:
profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }}
# The recordBaseline job uploads this marker after a successful deploy;
# on the native path, that requires both builds to succeed. A missing
# marker forces native builds so they can seed the baseline safely.
baseline-fingerprint-path: ${{ steps.baseline.outputs.path }}
# Hand the full fingerprint to recordBaseline through a short-lived
# artifact. It is uploaded unconditionally but promoted to the persistent
# baseline only after both native builds succeed.
- name: 🚀 Upload native fingerprint
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: native-fingerprint-${{ github.run_id }}
path: ${{ steps.fingerprint.outputs.current-fingerprint-path }}
retention-days: 1
if-no-files-found: error
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
uses: ./.github/actions/compile-i18n
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
- name: 🧹 Lint check
run: pnpm lint
- name: Prettier check
- name: 💅 Prettier check
run: pnpm prettier --check .
- name: Type check
- name: 🔎 Type check
run: pnpm typecheck
- name: 🔨 Setup EAS
@@ -128,38 +182,79 @@ jobs:
!steps.version.outputs.version-changed }}
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
# eas.json not used here, set EXPO_PUBLIC_ENV
- name: Env
env:
CHANNEL: ${{ inputs.channel || 'testflight' }}
GITHUB_SHA: ${{ github.sha }}
# eas.json not used here, so EXPO_PUBLIC_ENV must be written explicitly
- name: ✏️ Write environment variables
id: env
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "$json" > google-services.json
if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }}
uses: ./.github/actions/write-env
with:
env-token: ${{ secrets.ENV_TOKEN }}
sentry-dsn: ${{ secrets.SENTRY_DSN }}
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
expo-public-env: ${{ inputs.channel || 'testflight' }}
- name: 🏗️ Create Bundle
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
pnpm export
- name: 📦 Package Bundle and 🚀 Deploy
# Pin ONE bundle version for both publishes below. Each script used to call
# `date +%s` itself, so the same bytes reached denis and ota1 under versions
# seconds apart (observed: 1785102575 vs 1785102614). The version is part of
# the asset URL path, so each origin then served a manifest referencing a
# path only it had -- meaning a manifest fetched from one origin and assets
# fetched from the other 404. Both scripts fall back to `date +%s` when this
# is unset, so single-publisher callers are unaffected.
- name: 🔢 Pin bundle version
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
run: echo "BUNDLE_VERSION=$(date +%s)" >> "$GITHUB_ENV"
# denis on EKS has been the sole origin for updates.bsky.app since
# 2026-07-26, so it publishes FIRST: it is the path that actually serves
# clients. The legacy ota1 upload runs after it, and exists only so that
# rolling the Bunny origin back to ota1 would find current bundles there.
#
# The ordering is load-bearing, not cosmetic. While the legacy step ran
# first, its failure skipped these steps and nothing reached EITHER origin
# -- the dual-write took down the working path with it. Both steps are
# still required to pass, so a stale ota1 remains a loud failure, but the
# publish that serves users has already landed before the legacy one can
# fail.
#
# Both halves are removed together when ota1 is decommissioned (Phase 5).
- name: ☁️ Configure AWS credentials (denis)
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish
aws-region: us-east-2
- name: ⬇️ Setup denis CLI
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
uses: ./.github/actions/setup-denis
with:
release-tag: ${{ env.DENIS_RELEASE_TAG }}
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
- name: 🚀 Publish OTA to denis (S3)
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
run: pnpm use-build-number bash scripts/denisPublish.sh
env:
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
- name: 📦 Package Bundle and 🚀 Deploy (legacy ota1)
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
run: pnpm use-build-number bash scripts/bundleUpdate.sh
@@ -168,319 +263,109 @@ jobs:
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
CHANNEL_NAME: ${{ inputs.channel || 'testflight' }}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-26
concurrency:
group: ios-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' &&
needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app' }}
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: "26.4"
- name: ☕️ Assert Cocoapods version
run: |
EXPECTED=1.16.2
ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
exit 1
fi
- name: 💾 Cache Pods
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: pods-cache
with:
path: ./ios/Pods
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the pnpm version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
- name: 🔤 Compile translations
run: pnpm intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
id: env
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p ios
--profile testflight
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.channel == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.channel == 'testflight' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
uses: ./.github/workflows/build-submit-ios.yml
with:
profile: testflight
testFlightGroup: none
# OTA rebuilds don't need the xlarge builder used for releases
runner: macos-26
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
# so this workflow never hands the reusable workflow the entire repo secret store.
secrets:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
group: android-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' &&
needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app'}}
'bluesky-social/social-app' }}
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
# That job is skipped here (it needs a production tag build), but GitHub statically
# validates the reusable-workflow permission ceiling, so the caller must grant it.
permissions:
contents: write
uses: ./.github/workflows/build-submit-android.yml
with:
profile: testflight-android
runner: ubuntu-latest
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
# so this workflow never hands the reusable workflow the entire repo secret store.
secrets:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
# Advance the fingerprint baseline only after BOTH native builds have shipped
# the new native surface. This replaces the old actions/cache baseline, which
# only advanced on cache eviction and so silently froze - freezing meant every
# fingerprint looked changed and OTA updates stopped deploying entirely.
#
# On the native-build path, this runs only after both builds succeed. A
# successful OTA deploy also records its fingerprint, refreshing the
# persistent marker's retention without changing the native baseline.
#
# The persistent artifact replaces the old actions/cache marker without
# requiring a PAT or mutable repository variable. Each successful deploy adds
# an immutable marker; the next run reads the newest non-expired one using the
# built-in GITHUB_TOKEN.
recordBaseline:
name: Record fingerprint baseline
runs-on: ubuntu-latest
needs: [bundleDeploy, buildIfNecessaryIOS, buildIfNecessaryAndroid]
if: ${{ always() &&
(inputs.channel || 'testflight') == 'testflight' &&
needs.bundleDeploy.result == 'success' &&
(needs.bundleDeploy.outputs.changes-detected != 'true' ||
(needs.buildIfNecessaryIOS.result == 'success' &&
needs.buildIfNecessaryAndroid.result == 'success')) &&
github.repository == 'bluesky-social/social-app' }}
permissions:
actions: read
steps:
- name: Check for EXPO_TOKEN
- name: ⬇️ Download native fingerprint
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: native-fingerprint-${{ github.run_id }}
- name: 🧐 Validate native fingerprint
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
jq -e '.sources | type == "array"' native-fingerprint.json >/dev/null ||
(echo "::error::native fingerprint artifact was invalid; refusing to record it as the baseline." && exit 1)
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: "temurin"
java-version: "17"
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔤 Compile translations
run: pnpm intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
id: env
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile testflight-android
--local --output build.aab --non-interactive
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🚀 Submit to Google Play
run: pnpm eas submit -p android --profile testflight-android --non-interactive --path
build.aab
- name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
with:
version: "1.18.3"
- name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
keystore.jks
- name: 📦 Build signed universal APK
run: |
bundletool build-apks \
--bundle=build.aab \
--output=universal.apks \
--mode=universal \
--ks=keystore.jks \
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
- name: 📋 Rename to .zip for extraction
run: mv universal.apks universal.zip
- name: 📦 Extract universal APK
run: unzip -p universal.zip universal.apk > build.apk
- name: ⏰ Get a timestamp
id: timestamp
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
- name: 🚀 Upload Artifact
id: upload-artifact
- name: 🚀 Record fingerprint baseline
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
retention-days: 30
compression-level: 0
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
name: testflight-native-fingerprint
path: native-fingerprint.json
retention-days: 90
if-no-files-found: error
+5 -5
View File
@@ -53,19 +53,19 @@ jobs:
cancel-in-progress: false
steps:
- name: Checkout repository
- name: ⬇️ Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
- name: ☁️ Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2
- name: Claude
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
- name: 🤖 Claude
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
with:
use_bedrock: 'true'
additional_permissions: |
+5 -5
View File
@@ -39,19 +39,19 @@ jobs:
cancel-in-progress: true
steps:
- name: Checkout repository
- name: ⬇️ Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
- name: ☁️ Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2
- name: Claude review
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
- name: 🤖 Claude review
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
with:
use_bedrock: 'true'
additional_permissions: |
+10 -10
View File
@@ -17,32 +17,32 @@ jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
- name: ⬇️ Git Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go tooling
- name: 🔧 Set up Go tooling
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
- name: 📄 Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
- name: 🔍 Check
run: cd bskyweb/ && make check
- name: Build (binary)
- name: 🏗️ Build (binary)
run: cd bskyweb/ && make build
- name: Test
- name: 🧪 Test
run: cd bskyweb/ && make test
lint:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
- name: ⬇️ Git Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go tooling
- name: 🔧 Set up Go tooling
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
- name: 📄 Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
- name: 🧹 Lint
run: cd bskyweb/ && make lint
+17 -16
View File
@@ -21,11 +21,12 @@ jobs:
strategy:
fail-fast: false
matrix:
job: [lint, prettier, typecheck]
job:
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web']
steps:
- name: Check out Git repository
- name: ⬇️ Check out Git repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Verify Node version pins match package.json
- name: 🔍 Verify Node version pins match package.json
run: |
set -euo pipefail
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
@@ -51,16 +52,16 @@ jobs:
check "eas.json" "$v"
exit $rc
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🔧 Install node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
- name: 📦 pnpm install
run: pnpm install --frozen-lockfile
- name: Check & compile i18n
- name: 🔤 Check & compile i18n
run: pnpm intl:build
- name: Lint checks
- name: 🧹 Lint checks
run: pnpm ${{ matrix.job }}
# Aggregates the matrix results into a single stable check name so branch
# protection can require "Run linters" regardless of how many matrix jobs run.
@@ -72,7 +73,7 @@ jobs:
needs: [linting]
runs-on: ubuntu-latest
steps:
- name: Require linting to have succeeded
- name: Require linting to have succeeded
env:
RESULT: ${{ needs.linting.result }}
run: |
@@ -86,19 +87,19 @@ jobs:
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Check out Git repository
- name: ⬇️ Check out Git repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🔧 Install node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
- name: 📦 pnpm install
run: pnpm install --frozen-lockfile
- name: Check & compile i18n
- name: 🔤 Check & compile i18n
run: pnpm intl:build
- name: Run tests
- name: 🧪 Run tests
run: |
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
# Aggregates the sharded test results into a single stable check name so branch
@@ -111,7 +112,7 @@ jobs:
needs: [testing]
runs-on: ubuntu-latest
steps:
- name: Require testing to have succeeded
- name: Require testing to have succeeded
env:
RESULT: ${{ needs.testing.result }}
run: |
+2 -2
View File
@@ -144,7 +144,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
@@ -174,7 +174,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
+424
View File
@@ -0,0 +1,424 @@
---
name: Nightly Maestro E2E
on:
schedule:
- cron: "0 4 * * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nightly-maestro-e2e-${{ github.ref }}
cancel-in-progress: false
env:
CI: "1"
MAESTRO_VERSION: "2.6.1"
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
MAESTRO_CLI_NO_ANALYTICS: "1"
MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true"
MAESTRO_DISABLE_UPDATE_CHECK: "1"
jobs:
ios:
name: iOS Maestro E2E
if: github.repository == 'bluesky-social/social-app'
runs-on: macos-26-xlarge
timeout-minutes: 120
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: 🛠️ Select Xcode 26.4
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: "26.4"
- name: 🧰 Prepare E2E configuration
run: |
mkdir -p artifacts/ios
echo "Installing dependencies" > artifacts/ios/phase.txt
cp .env.example .env.development
cp google-services.json.example google-services.json
- name: 🔧 Set up Expo project
uses: ./.github/actions/setup-expo-project
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ☕️ Set up Java 17
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: temurin
java-version: "17"
- name: 📦 Install dev-env dependencies
run: pnpm --dir dev-env install --frozen-lockfile 2>&1 | tee artifacts/ios/dependencies.log
- name: 🔤 Compile translations
uses: ./.github/actions/compile-i18n
- name: 📥 Install Maestro 2.6.1
run: |
echo "Installing Maestro" > artifacts/ios/phase.txt
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \
| shasum -a 256 --check
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
"$RUNNER_TEMP/maestro/bin/maestro" --version | tee artifacts/ios/maestro-version.log
test "$("$RUNNER_TEMP/maestro/bin/maestro" --version)" = "$MAESTRO_VERSION"
- name: 📱 Boot one iOS simulator
run: |
echo "Booting iOS simulator" > artifacts/ios/phase.txt
device_name="iPhone 17"
runtime_name="iOS 26.5"
runtime_id=$(xcrun simctl list runtimes available --json | jq -r \
--arg name "$runtime_name" \
'[.runtimes[] | select(.name == $name and .isAvailable != false)] | first | .identifier // empty')
if [ -z "$runtime_id" ]; then
echo "The $runtime_name simulator runtime is not installed. Available iOS runtimes:" >&2
xcrun simctl list runtimes available --json | jq -r \
'.runtimes[] | select(.name | startswith("iOS")) | "- \(.name)"' >&2
exit 1
fi
device_type_id=$(xcrun simctl list devicetypes --json | jq -r \
--arg name "$device_name" \
'[.devicetypes[] | select(.name == $name)] | first | .identifier // empty')
if [ -z "$device_type_id" ]; then
echo "The $device_name simulator device type is not installed" >&2
exit 1
fi
udid=$(xcrun simctl list devices available --json | jq -r \
--arg runtime "$runtime_id" \
--arg name "$device_name" \
'[.devices[$runtime][]? | select(.name == $name)] | first | .udid // empty')
if [ -z "$udid" ]; then
udid=$(xcrun simctl create "$device_name" "$device_type_id" "$runtime_id")
fi
echo "IOS_UDID=$udid" >> "$GITHUB_ENV"
xcrun simctl shutdown all || true
xcrun simctl boot "$udid"
xcrun simctl bootstatus "$udid" -b
echo "Using $device_name on $runtime_name ($udid)"
- name: 🏷️ Mark iOS development client build phase
run: echo "Building the iOS development client" > artifacts/ios/phase.txt
- name: 🏗️ Build iOS development client
uses: ./.github/actions/eas-local-build
with:
platform: ios
profile: e2e
output: ${{ runner.temp }}/nightly-e2e-ios.tar.gz
log-path: artifacts/ios/build.log
- name: 📲 Install iOS development client
run: |
build_contents="$RUNNER_TEMP/nightly-e2e-ios-build"
mkdir -p "$build_contents"
tar -xzf "$RUNNER_TEMP/nightly-e2e-ios.tar.gz" -C "$build_contents"
app_path=$(find "$build_contents" -type d -name '*.app' -print -quit)
if [ -z "$app_path" ]; then
echo "The local EAS build did not contain an iOS simulator app" >&2
exit 1
fi
xcrun simctl install "$IOS_UDID" "$app_path" 2>&1 | tee -a artifacts/ios/build.log
- name: 🧪 Run iOS Maestro suite
run: .github/scripts/run-nightly-e2e.sh ios "$IOS_UDID"
- name: 🧹 Clean up iOS services and simulator
if: always()
run: .github/scripts/cleanup-nightly-e2e.sh ios "${IOS_UDID:-}"
- name: 🚀 Upload iOS E2E artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nightly-e2e-ios-${{ github.run_id }}
path: artifacts/ios
if-no-files-found: warn
retention-days: 7
android:
name: Android Maestro E2E
if: github.repository == 'bluesky-social/social-app'
# Linux-x64-32core is a repository-managed runner label.
runs-on: Linux-x64-32core
timeout-minutes: 120
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: 🧰 Prepare E2E configuration
run: |
mkdir -p artifacts/android
echo "Installing dependencies" > artifacts/android/phase.txt
cp .env.example .env.development
cp google-services.json.example google-services.json
- name: 🔧 Set up Expo project
uses: ./.github/actions/setup-expo-project
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ☕️ Set up Java 17
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: temurin
java-version: "17"
- name: 📦 Install dev-env dependencies
run: pnpm --dir dev-env install --frozen-lockfile 2>&1 | tee artifacts/android/dependencies.log
- name: 🔤 Compile translations
uses: ./.github/actions/compile-i18n
- name: 📥 Install Maestro 2.6.1
run: |
echo "Installing Maestro" > artifacts/android/phase.txt
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \
| shasum -a 256 --check
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
"$RUNNER_TEMP/maestro/bin/maestro" --version | tee artifacts/android/maestro-version.log
test "$("$RUNNER_TEMP/maestro/bin/maestro" --version)" = "$MAESTRO_VERSION"
- name: 📱 Install and boot one Android emulator
run: |
echo "Booting Android emulator" > artifacts/android/phase.txt
android_sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/usr/local/lib/android/sdk}}"
sdkmanager_bin="$android_sdk/cmdline-tools/latest/bin/sdkmanager"
avdmanager_bin="$android_sdk/cmdline-tools/latest/bin/avdmanager"
# API 35 emulator images have known stability problems in headless CI
# (see flutter/flutter#153445); the qemu process died deterministically
# on the first native stack-screen push with the API 35 image.
system_image="system-images;android-34;google_apis;x86_64"
if [ ! -x "$sdkmanager_bin" ] || [ ! -x "$avdmanager_bin" ]; then
echo "Android command-line tools were not found under $android_sdk" >&2
find "$android_sdk/cmdline-tools" -maxdepth 3 -type f \( \
-name sdkmanager -o -name avdmanager \
\) -print >&2 || true
exit 1
fi
export ANDROID_HOME="$android_sdk"
export ANDROID_SDK_ROOT="$android_sdk"
export PATH="$android_sdk/platform-tools:$android_sdk/emulator:$PATH"
echo "ANDROID_HOME=$android_sdk" >> "$GITHUB_ENV"
echo "ANDROID_SDK_ROOT=$android_sdk" >> "$GITHUB_ENV"
echo "$android_sdk/platform-tools" >> "$GITHUB_PATH"
echo "$android_sdk/emulator" >> "$GITHUB_PATH"
echo "Using Android SDK at $android_sdk"
yes | "$sdkmanager_bin" --sdk_root="$android_sdk" --licenses >/dev/null || true
"$sdkmanager_bin" --sdk_root="$android_sdk" \
"platform-tools" "emulator" "$system_image"
export ANDROID_AVD_HOME="$RUNNER_TEMP/.android/avd"
mkdir -p "$ANDROID_AVD_HOME"
echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV"
echo no | "$avdmanager_bin" create avd \
--force \
--name nightly-e2e \
--package "$system_image" \
--device pixel_6
# Reduce resolution to lighten the SwiftShader software-rendering
# workload, and raise RAM/cores/heap so the debug RN app has headroom.
# The default 2560MB RAM led to silent qemu crashes mid-flow.
printf 'hw.lcd.width=720\nhw.lcd.height=1600\nhw.lcd.density=280\nhw.ramSize=6144\nhw.cpu.ncore=4\nvm.heapSize=512\n' \
>> "$ANDROID_AVD_HOME/nightly-e2e.avd/config.ini"
if [ -e /dev/kvm ] && [ ! -w /dev/kvm ]; then
sudo chmod 666 /dev/kvm
fi
# Disable the emulator's Vulkan feature so graphics goes through the
# plain GLES SwiftShader translator. gfxstream Vulkan via SwiftShader
# Subzero crashed qemu silently at a deterministic rendering step;
# GLES-only is sufficient since the guest renders with skiagl.
#
# Run the launch in a background subshell so the emulator's exit
# status is recorded when it dies (it is otherwise backgrounded and
# its death is invisible). Write the emulator's real PID - not the
# subshell's - to emulator.pid, since cleanup-nightly-e2e.sh kills the
# PID from that file directly; killing the subshell would not kill the
# emulator child.
(
# wait returns the emulator's non-zero status on crash; set -e would
# abort the subshell before the status is logged.
set +e
"$android_sdk/emulator/emulator" @nightly-e2e \
-port 5554 \
-no-window \
-gpu swiftshader_indirect \
-feature -Vulkan \
-no-snapshot \
-noaudio \
-no-boot-anim \
-camera-back none \
> artifacts/android/emulator.log 2>&1 &
emulator_pid=$!
echo "$emulator_pid" > artifacts/android/emulator.pid
wait "$emulator_pid"
echo "Emulator exited with status $?" >> artifacts/android/emulator.log
) &
adb -s emulator-5554 wait-for-device
booted=false
for _ in $(seq 1 120); do
if [ "$(adb -s emulator-5554 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then
booted=true
break
fi
sleep 5
done
if [ "$booted" != "true" ]; then
echo "Android emulator did not finish booting" >&2
exit 1
fi
adb -s emulator-5554 shell settings put global window_animation_scale 0
adb -s emulator-5554 shell settings put global transition_animation_scale 0
adb -s emulator-5554 shell settings put global animator_duration_scale 0
- name: 🏷️ Mark Android development client build phase
run: echo "Building the Android development client" > artifacts/android/phase.txt
- name: 🏗️ Build Android development client
uses: ./.github/actions/eas-local-build
with:
platform: android
profile: e2e
output: ${{ runner.temp }}/nightly-e2e-android.apk
log-path: artifacts/android/build.log
- name: 📲 Install Android development client
run: |
adb -s emulator-5554 install -r "$RUNNER_TEMP/nightly-e2e-android.apk" \
2>&1 | tee -a artifacts/android/build.log
- name: 🧪 Run Android Maestro suite
run: .github/scripts/run-nightly-e2e.sh android emulator-5554
- name: 🩺 Capture emulator crash diagnostics
if: always()
run: |
{
echo "=== Emulator process status ==="
pgrep -fa "emulator.*nightly-e2e" || echo "Emulator process not found"
echo "=== Emulator exit status ==="
grep "Emulator exited" artifacts/android/emulator.log || echo "No emulator exit status recorded"
echo "=== OOM killer check (kernel) ==="
oom_lines=$(sudo dmesg 2>/dev/null | grep -iE "oom|killed process|out of memory" | tail -20)
echo "${oom_lines:-No kernel OOM evidence found (or dmesg unavailable)}"
echo "=== systemd-oomd check ==="
oomd_lines=$(journalctl -u systemd-oomd --no-pager 2>/dev/null | tail -20)
echo "${oomd_lines:-No systemd-oomd journal entries (or journalctl unavailable)}"
echo "=== journal kernel tail ==="
journalctl -k --no-pager 2>/dev/null | tail -30 || echo "journalctl -k unavailable"
echo "=== Emulator crash database ==="
ls -la /tmp/android-runner/emu-crash-*.db 2>/dev/null || echo "No crash database found"
} > artifacts/android/emulator-diagnostics.log 2>&1
- name: 🧹 Clean up Android services and emulator
if: always()
run: .github/scripts/cleanup-nightly-e2e.sh android emulator-5554
- name: 🚀 Upload Android E2E artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nightly-e2e-android-${{ github.run_id }}
path: artifacts/android
if-no-files-found: warn
retention-days: 7
report:
name: Report E2E failures
needs: [ios, android]
if: ${{ always() && github.repository == 'bluesky-social/social-app' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: ⬇️ Download iOS artifacts
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: nightly-e2e-ios-${{ github.run_id }}
path: downloaded-artifacts/ios
- name: ⬇️ Download Android artifacts
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: nightly-e2e-android-${{ github.run_id }}
path: downloaded-artifacts/android
- name: 🔗 Resolve artifact links
env:
GH_TOKEN: ${{ github.token }}
run: |
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}#artifacts"
jq -n --arg run "$run_url" '{ios: $run, android: $run}' > artifact-links.json
if gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" \
> artifact-response.json; then
jq --arg base "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts" \
--arg run "$run_url" \
'{
ios: (.artifacts | map(select(.name | startswith("nightly-e2e-ios-"))) | first | if . then ($base + "/" + (.id | tostring)) else $run end),
android: (.artifacts | map(select(.name | startswith("nightly-e2e-android-"))) | first | if . then ($base + "/" + (.id | tostring)) else $run end)
}' artifact-response.json > artifact-links.json
fi
- name: 📊 Summarize platform results
id: summary
env:
IOS_STATUS: ${{ needs.ios.result }}
ANDROID_STATUS: ${{ needs.android.result }}
run: |
node .github/scripts/summarize-maestro.mjs \
--ios-status "$IOS_STATUS" \
--android-status "$ANDROID_STATUS" \
--ios-root downloaded-artifacts/ios \
--android-root downloaded-artifacts/android \
--artifact-urls artifact-links.json \
--sha "$GITHUB_SHA" \
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
> e2e-summary.json
echo "notify=$(jq -r .notify e2e-summary.json)" >> "$GITHUB_OUTPUT"
echo "payload=$(jq -c .payload e2e-summary.json)" >> "$GITHUB_OUTPUT"
jq -r .githubSummary e2e-summary.json >> "$GITHUB_STEP_SUMMARY"
- name: 🔔 Notify Slack of E2E failures
if: steps.summary.outputs.notify == 'true'
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.E2E_FAILURES_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: ${{ steps.summary.outputs.payload }}
@@ -15,26 +15,26 @@ jobs:
contents: write
steps:
- name: Check out Git repository
- name: ⬇️ Check out Git repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🔧 Install node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
- name: 📦 pnpm install
run: pnpm install --frozen-lockfile
- name: Extract language strings
- name: 🔤 Extract language strings
run: pnpm intl:extract
- name: Create commit
- name: 📝 Create commit
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
with:
commit_message: Nightly source-language update
file_pattern: ./src/locale/locales/en/messages.po
- name: Push source lang to Crowdin
- name: 🚀 Push source lang to Crowdin
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_sources: true
-221
View File
@@ -1,221 +0,0 @@
---
name: PR Comment Trigger
on:
issue_comment:
types: [created]
# Permissions are granted per-job below; anything unlisted defaults to none
permissions: {}
jobs:
handle-comment:
if: github.event.issue.pull_request
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-deploy: ${{ steps.check-org.outputs.result }}
steps:
- name: Check if bot is mentioned
id: check-mention
env:
COMMENT: ${{ github.event.comment.body }}
run: |
if [[ "$COMMENT" == *"@github-actions"* ]] || \
[[ "$COMMENT" == *"github-actions[bot]"* ]]; then
bot_mentioned=true
else
bot_mentioned=false
fi
if [[ "$COMMENT" == *"ota"* ]]; then
has_ota=true
else
has_ota=false
fi
if [[ "$bot_mentioned" == "true" ]] && [[ "$has_ota" == "true" ]]; then
echo "mentioned=true" >> $GITHUB_OUTPUT
else
echo "mentioned=false" >> $GITHUB_OUTPUT
fi
- name: Check commenter has write access
if: steps.check-mention.outputs.mentioned == 'true'
id: check-org
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
try {
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login
});
const hasAccess = ['admin', 'write'].includes(perm.permission);
console.log(`User has ${perm.permission} access`);
return hasAccess;
} catch(error) {
console.log('User has no repository access');
return false;
}
bundle-deploy:
name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest
needs: [handle-comment]
if: needs.handle-comment.outputs.should-deploy == 'true'
permissions:
contents: read
pull-requests: write
steps:
- name: Get PR HEAD SHA
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
id: pr-info
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: process.env.ISSUE_NUMBER,
});
// This workflow runs with repo secrets in scope, so never build
// code from a fork: the commenter authorizes the deploy, but a
// fork controls what code would run during it
const expected = `${context.repo.owner}/${context.repo.repo}`;
const head = pr.data.head.repo?.full_name;
if (head !== expected) {
core.setFailed(`OTA deploys are only allowed for branches in ${expected}, not forks (got ${head})`);
return;
}
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
console.log(`PR HEAD REF: ${pr.data.head.ref}`);
core.setOutput('head-sha', pr.data.head.sha);
core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
An OTA deployment has been requested and is now running for `${{ steps.pr-info.outputs.head-sha }}`.
[Here is some music to listen to while you wait...](https://www.youtube.com/watch?v=VBlFHuCzPgY)
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.pr-info.outputs.head-sha }}
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
- name: Env
id: env
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_ENV=testflight" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "$json" > google-services.json
- name: 🏗️ Create Bundle
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm export
- name: 📦 Package Bundle and 🚀 Deploy
run: pnpm use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
CHANNEL_NAME: pull-request-${{ github.event.issue.number }}
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
<img src="https://bsky-qr.vercel.app?channel=pull-request-$ISSUE_NUMBER" width=300 height=300>
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was unsuccessful. See action logs for more details.
---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
+178 -14
View File
@@ -15,12 +15,56 @@ concurrency:
# Permissions are granted per-job below; anything unlisted defaults to none.
# pull-requests: write is needed by sticky-pull-request-comment to post the
# bundle-size and fingerprint diffs
# bundle-size and fingerprint diffs and the PR OTA install link
permissions: {}
# denis release tag in bluesky-social/tango whose linux-amd64 binary the PR OTA
# job downloads. Bump this one line to roll denis.
env:
DENIS_RELEASE_TAG: denis-v0.1.1
jobs:
# Populate this from main so every PR can restore the same trusted baseline.
webpack-analyzer-base:
runs-on: ubuntu-24.04
if: ${{ github.event_name == 'push' }}
permissions:
contents: read
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: stats.json
key: stats-base-main-${{ github.sha }}
- name: 🔦 Generate stats file for base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
pnpm install
pnpm intl:build
pnpm generate-webpack-stats-file
- name: ⬆️ Save base stats to cache
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: stats.json
key: stats-base-main-${{ github.sha }}
webpack-analyzer:
runs-on: ubuntu-22.04
runs-on: ubuntu-24.04
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
permissions:
contents: read
@@ -34,12 +78,12 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
- name: Ensure tracking relevant branches and checkout base
- name: 🌿 Ensure tracking relevant branches and checkout base
env:
HEAD_REF: ${{ github.head_ref }}
BASE_REF: ${{ github.base_ref }}
@@ -47,13 +91,13 @@ jobs:
git checkout $HEAD_REF
git checkout $BASE_REF
- name: Get the base commit
- name: 🔍 Get the base commit
id: base-commit
env:
BASE_REF: ${{ github.base_ref }}
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
- name: Merge PR commit
- name: 🔀 Merge PR commit
env:
HEAD_REF: ${{ github.head_ref }}
run: |
@@ -71,15 +115,18 @@ jobs:
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
# Restore-only prevents PR-scoped fallback builds from creating caches.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: stats-base.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
path: stats.json
key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
- name: Restore to base commit
- name: Restore to base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
env:
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
run: |
git reset HEAD~
git reset "$BASE_COMMIT"
git restore .
- name: 🔦 Generate stats file from base commit
@@ -88,13 +135,12 @@ jobs:
pnpm install
pnpm intl:build
pnpm generate-webpack-stats-file
mv stats.json stats-base.json
- name: Get diff
id: get-diff
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
with:
base_path: "stats-base.json"
base_path: "stats.json"
pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
@@ -127,7 +173,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
@@ -179,3 +225,121 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr edit "$PR_NUMBER" --remove-label "bot: fingerprint changed" || true
# Automatic per-PR OTA preview, published to the pull-request-<N> channel on
# denis. Replaces the old `@github-actions ota` comment trigger. Gated to
# same-repo PRs (fork guard): a branch can only exist in this repo if someone
# with write access pushed it, so an outside contributor (who can only open a
# PR from a fork) never runs this job with the denis publish role in scope.
# This matches the fork-guard gate the other jobs in this workflow use;
# author_association is deliberately NOT checked (it can't identify a private
# org member and would skip their PRs).
#
# Bot authors are excluded: Dependabot pushes in-repo branches, so it passes
# the fork guard, but GitHub withholds repo secrets from Dependabot-triggered
# runs. EXPO_TOKEN is then empty and the job fails at setup — a red check on
# every dependabot PR. There is no OTA preview worth publishing for a
# dependency bump anyway.
publish-pr-ota:
name: Publish PR OTA to denis
runs-on: ubuntu-latest
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.type != 'Bot'
concurrency:
group: pr-ota-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
id-token: write
contents: read
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: 🛠️ Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: 🔤 Compile translations
uses: ./.github/actions/compile-i18n
- name: ✏️ Write environment variables
id: env
uses: ./.github/actions/write-env
with:
env-token: ${{ secrets.ENV_TOKEN }}
sentry-dsn: ${{ secrets.SENTRY_DSN }}
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
expo-public-env: testflight
- name: 🏗️ Create Bundle
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
pnpm export
- name: ☁️ Configure AWS credentials (denis, PR-scoped)
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::007404326489:role/denis-ci-publish-pr
aws-region: us-east-2
# Defense-in-depth: the base role is already scoped to pr/*, but narrow
# this session further to just THIS PR's prefix so a bug can't write to
# another PR's objects or the prod tree.
inline-session-policy: |-
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::bsky-denis-ota-prod/pr/${{ github.event.pull_request.number }}/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::bsky-denis-ota-prod",
"Condition": {
"StringLike": { "s3:prefix": "pr/${{ github.event.pull_request.number }}/*" }
}
}
]
}
- name: ⬇️ Setup denis CLI
uses: ./.github/actions/setup-denis
with:
release-tag: ${{ env.DENIS_RELEASE_TAG }}
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
- name: 🚀 Publish OTA to denis (S3)
run: pnpm use-build-number bash scripts/denisPublish.sh
env:
RUNTIME_VERSION: ''
CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }}
comment-pr-ota:
name: Comment PR OTA install link
needs: publish-pr-ota
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: 💬 Drop OTA install comment
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: pull-request-ota
message: |
The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}" width="300" height="300" alt="QR code for the PR OTA deployment">
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}`
+3 -3
View File
@@ -13,14 +13,14 @@ jobs:
runs-on: ubuntu-latest
if: github.repository == 'bluesky-social/social-app'
steps:
- name: Checkout public repo
- name: ⬇️ Checkout public repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
# Don't persist the checkout auth header; the push below authenticates
# with the app token embedded in the remote URL instead
persist-credentials: false
- name: Generate GitHub App Token
- name: 🔑 Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
@@ -33,7 +33,7 @@ jobs:
# .github/workflows/, which GitHub refuses to push without it.
permission-contents: write
permission-workflows: write
- name: Push to internal repo
- name: 🚀 Push to internal repo
env:
TOKEN: ${{ steps.app-token.outputs.token }}
run: |
+7 -7
View File
@@ -14,33 +14,33 @@ jobs:
name: No manual pnpm-lock.yaml edits
runs-on: ubuntu-latest
steps:
- name: Check out PR HEAD
- name: ⬇️ Check out PR HEAD
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Fetch base branch
- name: ⬇️ Fetch base branch
env:
BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🔧 Install node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
- name: Reset pnpm-lock.yaml to base
- name: Reset pnpm-lock.yaml to base
env:
BASE_REF: ${{ github.base_ref }}
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: pnpm install
- name: 📦 pnpm install
# Fine to skip scripts since we don't run any code
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
- name: Verify pnpm-lock.yaml
- name: 🔍 Verify pnpm-lock.yaml
run: |
git diff --quiet --exit-code || {
echo '::error::`pnpm-lock.yaml` does not match what pnpm would generate given the base `pnpm-lock.yaml` and the head `package.json`.'
+2 -2
View File
@@ -20,12 +20,12 @@ jobs:
name: Audit workflows with zizmor
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
- name: ⬇️ Check out Git repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run zizmor
- name: 🛡️ Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with:
# Annotate the PR directly instead of uploading SARIF to the
+3
View File
@@ -134,3 +134,6 @@ bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
.claude/worktrees
# pnpm
.pnpm-store/
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/pnpm-lock.yaml ./
COPY ./bskyogcard/pnpm-workspace.yaml ./
RUN npm install --global pnpm@11.10.0
RUN npm install --global pnpm@11.13.1
RUN pnpm install --frozen-lockfile
COPY ./bskyogcard ./
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.10.0 && \
npm install --global pnpm@11.13.1 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
+16
View File
@@ -44,6 +44,12 @@ appId: xyz.blueskyweb.app
id: "e2eRefreshHome"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing. Tapping replyBtn right
# after the previous publish can race the closing composer on Android.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply text only"
- tapOn:
id: "composerPublishBtn"
@@ -51,6 +57,11 @@ appId: xyz.blueskyweb.app
id: "composeFAB"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply with an image"
- tapOn:
id: "openMediaBtn"
@@ -63,6 +74,11 @@ appId: xyz.blueskyweb.app
id: "composeFAB"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply with a https://example.com link card"
- tapOn:
id: "composerPublishBtn"
+81 -44
View File
@@ -29,51 +29,88 @@ appId: xyz.blueskyweb.app
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
# Set alice-favs first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "Following"
# Reordering feeds is driven by a drag on the feed-drag-handle. Maestro cannot
# activate the RNGH Pan gesture from a synthetic swipe on Android (proven
# twice - coordinate swipes never register the pan), so the reorder
# verification below runs on iOS only. If Android drag coverage is needed,
# revisit with the SavedFeedsA11y move buttons rather than a swipe.
- runFlow:
when:
platform: iOS
commands:
# Set alice-favs first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- assertVisible:
id: "saveChangesBtn"
enabled: true
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "Following"
# Set following first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "Following"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
# Set following first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- assertVisible:
id: "saveChangesBtn"
enabled: true
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "Following"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
# On Android, the reorder path above is skipped. Smoke-test that the feeds
# edit screen opens and the pinned feeds render, then return to a valid state.
- runFlow:
when:
platform: Android
commands:
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- assertVisible: "Following"
- assertVisible: "alice-favs"
# Two back presses to reach Home: the first pops the saved-feeds editor
# back to the Feeds screen, the second pops Feeds back to Home. On iOS the
# equivalent path saves changes first (saveChangesBtn calls
# navigation.goBack), so a single "Go back" there already lands on Home.
# This Android smoke branch never saves, so it needs the extra pop to
# leave the screen state on Home, which the shared steps below expect.
- tapOn: "Go back"
- tapOn: "Go back"
# Remove following
- tapOn: "Open drawer menu"
@@ -15,6 +15,24 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eStartOnboarding"
- tapOn: "Open avatar creator"
# The avatar-creator bottom sheet (Dialog.Inner, non-scrollable) opens only
# half-expanded on the short E2E emulator (720x1600), a ~220px sliver with the
# emoji grid below the fold. It is NOT a scroll view, so scrollUntilVisible's
# swipe grabs the sheet's own drag gesture and flings it closed. Instead, drag
# the sheet upward to expand it to full height, which brings the picker into
# view. iOS opens the sheet fully already, so this is Android-only.
- runFlow:
when:
platform: Android
commands:
- swipe:
label: "Drag the bottom sheet up to expand it"
start: "50%, 90%"
end: "50%, 20%"
duration: 600
- extendedWaitUntil:
visible: "Select an emoji"
timeout: 10000
- tapOn: "Select the zap emoji as your avatar"
- tapOn:
label: "Tap on yellow"
@@ -22,6 +40,20 @@ appId: xyz.blueskyweb.app
- tapOn: "Done"
- waitForAnimationToEnd
- tapOn: "Select an avatar"
# Reopening the creator sheet lands on the same half-expanded sliver on
# Android, so expand it again before reaching for the emoji grid. No-op on iOS.
- runFlow:
when:
platform: Android
commands:
- swipe:
label: "Drag the bottom sheet up to expand it"
start: "50%, 90%"
end: "50%, 20%"
duration: 600
- extendedWaitUntil:
visible: "Select an emoji"
timeout: 10000
- tapOn: "Select the atom emoji as your avatar"
- tapOn: "Done"
- waitForAnimationToEnd
+22 -7
View File
@@ -16,13 +16,28 @@ appId: xyz.blueskyweb.app
id: "e2eStartOnboarding"
- tapOn: "Select an avatar"
- waitForAnimationToEnd
- assertVisible: "Photos"
- assertVisible: "Collections"
- tapOn:
point: "50%,22%"
- waitForAnimationToEnd
- tapOn: "Done"
- waitForAnimationToEnd
- runFlow:
when:
platform: iOS
commands:
- assertVisible: "Photos"
- assertVisible: "Collections"
- tapOn:
point: "50%,22%"
- waitForAnimationToEnd
- tapOn: "Done"
- waitForAnimationToEnd
- runFlow:
when:
platform: Android
commands:
# The system photo picker opened here shows MediaStore photos, which
# the e2e run doesn't seed (media is seeded into app-scoped storage for
# the composer's mocked picker instead). With no photo to pick, dismiss
# the picker and continue - onContinue falls back to the generated
# placeholder avatar, and nothing later in the flow depends on the image.
- back
- waitForAnimationToEnd
- tapOn:
id: "onboardingContinue"
- assertVisible: "What are your interests?"
+5 -2
View File
@@ -45,7 +45,7 @@ appId: xyz.blueskyweb.app
id: "editProfileSaveBtn"
- assertNotVisible:
id: "editProfileModal"
- assertVisible: "Alicia"
- assertVisible: ".*Alicia.*"
- assertVisible: "One cool hacker"
# Remove display name and description via the edit profile modal
@@ -64,7 +64,10 @@ appId: xyz.blueskyweb.app
id: "editProfileSaveBtn"
- assertNotVisible:
id: "editProfileModal"
- assertVisible: "alice.test"
# The display-name node renders the handle as a Text with a nested badge View
# once the display name is cleared, so the a11y text is not the bare handle
# string on Android. Match it as a substring instead.
- assertVisible: ".*alice\\.test.*"
- assertNotVisible: "One cool hacker"
# Set avi and banner via the edit profile modal
@@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app
text: "Send report to Dev-env Moderation"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
- extendedWaitUntil:
notVisible:
id: "report:dialog"
timeout: 20000
+4 -2
View File
@@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app
text: "Send report to Dev-env Moderation"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
- extendedWaitUntil:
notVisible:
id: "report:dialog"
timeout: 20000
@@ -39,5 +39,7 @@ appId: xyz.blueskyweb.app
text: Your report will be sent to Dev-env Moderation.*
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
- extendedWaitUntil:
notVisible:
id: "report:dialog"
timeout: 20000
@@ -29,5 +29,7 @@ appId: xyz.blueskyweb.app
- hideKeyboard
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
- extendedWaitUntil:
notVisible:
id: "report:dialog"
timeout: 20000
+85 -12
View File
@@ -20,6 +20,12 @@ appId: xyz.blueskyweb.app
- inputText: "Test thread"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to close and the home feed to settle before signing
# out. Without a settle guard the next action can race the closing composer.
- extendedWaitUntil:
visible:
id: "composeFAB"
timeout: 10000
# Login, reply to the thread, and log out
- tapOn:
@@ -31,9 +37,19 @@ appId: xyz.blueskyweb.app
id: "viewHeaderHomeFeedPrefsBtn"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 1"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to close before signing out.
- extendedWaitUntil:
visible:
id: "composeFAB"
timeout: 10000
# Login, confirm notification exists, mute thread, and log out
- tapOn:
@@ -45,10 +61,8 @@ appId: xyz.blueskyweb.app
id: "viewHeaderHomeFeedPrefsBtn"
- tapOn:
id: "bottomBarNotificationsBtn"
- assertVisible:
id: "feedItem-by-bob.test"
- tapOn:
id: "feedItem-by-bob.test"
- assertVisible: ".*Reply 1.*"
- tapOn: ".*Reply 1.*"
- tapOn:
id: "postDropdownBtn"
childOf:
@@ -67,16 +81,78 @@ appId: xyz.blueskyweb.app
id: "bottomBarProfileBtn"
- tapOn:
id: "profilePager-selector-1"
- tapOn:
id: "replyBtn"
# Both replies target the thread root ("Test thread" by alice), which sits at
# the top of bob's Replies tab. That tab renders each post in the thread with
# its own replyBtn, so scope the tap to the root post's card
# (feedItem-by-alice.test) rather than relying on which replyBtn Maestro picks
# first. This keeps both reply taps deterministic regardless of list order or
# how many posts have rendered.
#
# Even with the close-gating below, the replyBtn tap can land on a recycled list
# row while the author feed re-renders after a publish, and be swallowed so the
# composer never opens. Wrapping the tap + open-wait in retry makes opening the
# composer idempotent: a swallowed tap just re-taps until the publish button
# appears. A first-try success does not retry.
- retry:
maxRetries: 3
commands:
- tapOn:
id: "replyBtn"
childOf:
id: "feedItem-by-alice.test"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 2"
- tapOn:
id: "composerPublishBtn"
- tapOn:
id: "replyBtn"
# Wait for the composer to actually close before opening it again. replyBtn
# stays in the accessibility tree behind the open composer sheet, so waiting on
# its visibility returns immediately and does not gate on the close animation or
# the author-feed re-render that follows a post - the next replyBtn tap then
# fires mid-transition and is swallowed, so the composer never opens. Gate on
# the publish button disappearing (the composer is gone), then confirm the
# reply button underneath is back and let animations settle.
- extendedWaitUntil:
notVisible:
id: "composerPublishBtn"
timeout: 15000
- extendedWaitUntil:
visible:
id: "replyBtn"
timeout: 10000
- waitForAnimationToEnd
# As with Reply 2: even after gating on the composer close, this tap can hit a
# recycled row during the post-publish feed re-render and be swallowed, so wrap
# the open in retry to make it idempotent.
- retry:
maxRetries: 3
commands:
- tapOn:
id: "replyBtn"
childOf:
id: "feedItem-by-alice.test"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 3"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to actually close before signing out. As above,
# replyBtn stays visible behind the sheet, so gate on the publish button
# disappearing first, then confirm the reply button underneath has returned.
- extendedWaitUntil:
notVisible:
id: "composerPublishBtn"
timeout: 15000
- extendedWaitUntil:
visible:
id: "replyBtn"
timeout: 10000
# Login, confirm notifications dont exist, unmute the thread, ~~confirm notifications exist~~
# Mute thread behaviour no longer change old notifications after muting/unmuting a thread -sfn
@@ -92,10 +168,7 @@ appId: xyz.blueskyweb.app
- assertVisible: ".*Reply 1.*"
- assertNotVisible: ".*Reply 2.*"
- assertNotVisible: ".*Reply 3.*"
- assertVisible:
id: "feedItem-by-bob.test"
- tapOn:
id: "feedItem-by-bob.test"
- tapOn: ".*Reply 1.*"
- tapOn:
id: "postDropdownBtn"
childOf:
+17 -13
View File
@@ -9,23 +9,27 @@ appId: xyz.blueskyweb.app
when:
platform: iOS
commands:
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
- runFlow:
when:
visible: 'Open in "Bluesky"'
commands:
- tapOn: Open
- extendedWaitUntil:
visible: "http://localhost:8081"
timeout: 60000
- tapOn: "http://localhost:8081"
- runFlow:
when:
platform: Android
commands:
- tapOn: 'http://localhost:8081'
- runFlow:
label: "Dismiss Expo dev menu"
when:
visible: "Continue"
commands:
- back
- extendedWaitUntil:
visible: "http://10.0.2.2:8081"
timeout: 60000
- tapOn: "http://10.0.2.2:8081"
- extendedWaitUntil:
visible: "Continue"
timeout: 180000
- tapOn: "Continue"
- back
- extendedWaitUntil:
visible:
id: e2eProxyHeaderInput
timeout: 180000
- tapOn:
id: e2eProxyHeaderInput
- inputText: ${output.result}
-1
View File
@@ -55,7 +55,6 @@ module.exports = function (_config) {
icon: './assets/app-icons/ios_icon_default_next.png',
userInterfaceStyle: 'automatic',
primaryColor: '#006AFF',
newArchEnabled: false,
ios: {
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006AFF" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006aff" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 250 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 1000 500"><defs><clipPath id="cp1" clipPathUnits="userSpaceOnUse"><path d="M626.36 100.49c-5.11 3.07-10.03 6.4-14.6 9.83-16.71 12.55-33.83 29.35-50.01 47.72-20.77 23.59-39.97 49.76-54.68 72.84-8.59 13.48-15.65 25.91-20.6 36.14-17.68-36.56-62.26-101-107.36-141.8-5.99-5.42-11.99-10.44-17.94-14.9-26.96-20.24-66.21-37.06-85.02-17.49-6.73 6.99-10.87 18.59-10.87 36.4 0 13.51 7.75 113.48 12.29 129.72 12.82 45.8 53.13 63.9 95.24 64.44l1.72 4.1c-66.07 20.14-79.15 64.89-35.52 109.66 40.27 41.32 70.75 42.51 93.25 28.05 29.66-19.06 45.43-65.32 51.38-82.78q.71-2.11 1.24-3.62c1.07-3.02 1.58-4.08 1.59-2.4.01-2.24.91.39 2.83 6.02 9.24 27.14 42.23 123.92 114.26 79.79 9.44-5.78 19.55-13.95 30.36-25.06 15.25-15.63 23.56-31.27 25.13-45.86 1.64-15.29-4.17-29.4-17.18-41.17l-12.59-30.19c29.25-6.89 54.25-24.56 64.85-56.99.43-1.32.85-2.63 1.24-3.99 4.54-16.24 12.28-116.21 12.28-129.72 0-59.2-45.42-50.3-81.29-28.74"/></clipPath></defs><style>.s0{fill:#7bb244}.s1{fill:#008442}.s3{fill:#008cd2}.s4{fill:#e7451e}.s5{fill:#f6a700}.s6{fill:#fff}.s7{mix-blend-mode:multiply;fill:#e7451e}.s8{fill:#4b9754}</style><g id="デザイン"><g id="&lt;Group&gt;"><path id="&lt;Path&gt;" d="M362.1 110.32c-26.96-20.24-66.21-37.06-85.03-17.49l96.66 230.56c9.77.13 19.64-.69 29.28-2.33-10.04 1.71-19.2 3.88-27.56 6.43l57.74 137.71c29.66-19.06 45.43-65.32 51.38-82.78q.71-2.11 1.24-3.62L380.04 125.22c-5.99-5.42-11.99-10.44-17.94-14.9" class="s0"/><path d="M266.21 129.23c0 13.51 7.75 113.48 12.29 129.72 12.81 45.8 53.13 63.9 95.23 64.44L277.07 92.83c-6.72 6.99-10.86 18.59-10.86 36.4m73.73 307.93c40.26 41.31 70.75 42.5 93.25 28.04l-57.74-137.71c-66.06 20.14-79.14 64.89-35.51 109.67" class="s1"/><path d="M696.29 258.95c4.55-16.24 12.29-116.21 12.29-129.72 0-59.2-45.42-50.31-81.29-28.74l67.76 162.45q.66-1.96 1.24-3.99" class="s0"/><path d="m642.8 350.12 17.17 41.17c1.64-15.28-4.16-29.4-17.17-41.17m52.25-87.18-67.76-162.45c-5.11 3.07-10.03 6.4-14.6 9.83-16.71 12.55-33.84 29.35-50.01 47.72l67.53 161.89c29.25-6.89 54.25-24.56 64.84-56.99m-207.65 4.08c-17.69-36.57-62.27-101-107.36-141.8L485.81 378.8c1.07-3.02 1.58-4.09 1.59-2.4.01-2.24.91.39 2.82 6.02 9.25 27.14 42.24 123.92 114.27 79.79L508 230.88c-8.59 13.48-15.65 25.91-20.6 36.14" class="s1"/><path d="M571.78 321.06c19.44 3.31 39.79 3.27 58.43-1.13l-67.53-161.89c-20.78 23.59-39.97 49.76-54.68 72.84l96.49 231.33c9.44-5.78 19.54-13.95 30.36-25.05 15.24-15.64 23.56-31.28 25.12-45.87l-17.17-41.17c-14.63-13.24-38.36-23.5-71.02-29.06" class="s0"/><path d="m537.4 4-17.72 143.35 88.33-113.64z" style="fill:#f4ae1a"/><path d="m895.69 371.73-230.18-31.9 148.57 101.66z" class="s3"/><path d="m90.72 95.32 159.34 126.77L0 162.43z" class="s4"/><path d="m148.73 361.28 250.64-7.29-290.74 141.27z" class="s5"/><g id="&lt;Clip Group&gt;" clip-path="url(#cp1)"><path id="&lt;Compound Path&gt;" fill-rule="evenodd" d="m569.43 371.9 51.37 123.15-4.45 1.85-51.37-123.16c-10.11 3.92-21.06 6.16-32.54 6.16-49.82 0-90.36-40.54-90.36-90.36 0-36.66 21.97-68.22 53.41-82.38l-33.88-81.21 4.45-1.86 33.88 81.23c10.11-3.92 21.04-6.15 32.5-6.15 49.83 0 90.37 40.54 90.37 90.37 0 36.64-21.96 68.19-53.38 82.36M501.8 209.77l65.77 157.68c29.68-13.44 50.42-43.27 50.42-77.91 0-47.17-38.38-85.55-85.55-85.55-10.81 0-21.11 2.1-30.64 5.78m61.32 159.52-65.77-157.68c-29.7 13.43-50.45 43.27-50.45 77.93 0 47.16 38.37 85.54 85.54 85.54 10.82 0 21.14-2.1 30.68-5.79" class="s6"/></g><path d="m763.79 80.37-201.27 208 304.16 14z" class="s4"/><path d="M970.03 253.88c-48.46 57.07-138.53 68.27-201.19 25.02-62.65-43.24-74.16-124.56-25.7-181.63 48.46-57.06 138.53-68.26 201.18-25.02 62.66 43.25 74.17 124.57 25.71 181.63" class="s6"/><path d="M914.45 112.85s-47.72-9.42-60.98-3.44c0 0-20.85 41.71-19.79 49.19 0 0 42.36 33.2 51.62 34.93 0 0 41.25-18.78 47.5-27.04 0 0-6.8-44.96-18.35-53.64" class="s5"/><path d="M745.71 149.33s8.94 18.59 30.66 28.61c0 0-8.04 41.65-6.08 52.61 0 0-29.32 1.2-36.92-.32 0 0-13.68-21.28-15.2-48.63 0 0 11.16-22.62 27.54-32.27" class="s7"/><path d="M882.41 248.11s19.49 18.58 36.64 21.56c0 0-10.87 18.86-27.62 28.95 0 0-46.24 8.07-56.83.06 0 0-12.6-21.27-11.02-30.65 0 0 47.78-9.62 58.83-19.92" class="s8"/><path d="M996.29 160.32s-11.03 24.46-22.17 27.94c0 0 1.59 34.85-6.18 48.82l10.12.75s24.82-41.68 18.23-77.51" class="s7"/><path d="m908.15 55.46-6.72 1.39s22.77 2.58 34.07 29c0 0 29.75 6.27 39.52 19.76 0 0-11.63-20.9-27.55-31.65-15.91-10.75-39.32-18.5-39.32-18.5" class="s8"/><path d="m829.78 51.19 9.62 1.95s-22.02 16.72-20.32 25.78c0 0-32.01 5-52.33 20.56l-7.5-13.26s16.01-15.28 34.08-24.48c18.08-9.21 36.45-10.55 36.45-10.55" class="s3"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 426 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#080B12" stroke-linecap="round" stroke-width="1.5" d="m1.417 28.645 7.586-5.676a5.33 5.33 0 0 1 6.867.809c3.98 4.286 8.594 8.182 14.88 8.182 5.794 0 9.633-2.147 13.333-5.847m-38 18.637h33.334a5.333 5.333 0 0 0 5.333-5.333V6.083A5.333 5.333 0 0 0 39.417.75H6.083A5.333 5.333 0 0 0 .75 6.083v33.334a5.333 5.333 0 0 0 5.333 5.333ZM36.75 14.083a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#080b12" stroke-linecap="round" stroke-width="1.5" d="m1.417 28.645 7.586-5.676a5.33 5.33 0 0 1 6.867.809c3.98 4.286 8.594 8.182 14.88 8.182 5.794 0 9.633-2.147 13.333-5.847m-38 18.637h33.334a5.333 5.333 0 0 0 5.333-5.333V6.083A5.333 5.333 0 0 0 39.417.75H6.083A5.333 5.333 0 0 0 .75 6.083v33.334a5.333 5.333 0 0 0 5.333 5.333ZM36.75 14.083a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 491 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185FE" d="M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185fe" d="M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z"/></svg>

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 557 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021s5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.87.87 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#ffc404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021s5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.87.87 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 838 B

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4Zm4-10a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5Zm-5 8a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4Zm4-10a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5Zm-5 8a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 579 B

After

Width:  |  Height:  |  Size: 579 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>

Before

Width:  |  Height:  |  Size: 355 B

After

Width:  |  Height:  |  Size: 355 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 233 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a50 50 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232q.369-.007.741-.007c21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.6 38.6 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a50 50 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232q.369-.007.741-.007c21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.6 38.6 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0a7aff"/><stop offset="1" stop-color="#59b9ff"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11.5" fill="#1183FE"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.175a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11.5" fill="#1183fe"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.175a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 354 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185FE" d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.15 4.15 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.15 4.15 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.15 4.15 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.15 4.15 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.15 4.15 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.15 4.15 0 0 0 .776-3.404 4.154 4.154 0 0 1 4-5.017 4.15 4.15 0 0 0 3.146-1.515Z"/><path fill="#fff" fill-rule="evenodd" d="M17.861 8.26a1.44 1.44 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" clip-rule="evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185fe" d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.15 4.15 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.15 4.15 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.15 4.15 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.15 4.15 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.15 4.15 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.15 4.15 0 0 0 .776-3.404 4.154 4.154 0 0 1 4-5.017 4.15 4.15 0 0 0 3.146-1.515Z"/><path fill="#fff" fill-rule="evenodd" d="M17.861 8.26a1.44 1.44 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 733 B

+14 -12
View File
@@ -1,6 +1,8 @@
/**
* @param {import("@babel/core").ConfigAPI} api
* @returns {import("@babel/core").InputOptions}
*/
module.exports = function (api) {
api.cache(true)
const isTestEnv = process.env.NODE_ENV === 'test'
return {
presets: [
[
@@ -10,7 +12,7 @@ module.exports = function (api) {
native: {
// Disable ESM -> CJS compilation because Metro takes care of it.
// However, we need it in Jest tests since those run without Metro.
disableImportExportTransform: !isTestEnv,
disableImportExportTransform: !api.env('test'),
},
},
],
@@ -29,15 +31,15 @@ module.exports = function (api) {
},
},
],
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
// cannot use `env` field because it will put them after
// the `react-native-worklets/plugin` plugin
...(api.env('test')
? ['@babel/plugin-transform-class-static-block']
: []),
...(api.env('production') ? ['transform-remove-console'] : []),
'react-native-worklets/plugin', // NOTE: this plugin MUST be last
],
env: {
production: {
plugins: ['transform-remove-console'],
},
test: {
plugins: ['@babel/plugin-transform-class-static-block'],
},
},
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.10.0",
"version": "11.13.1",
"onFail": "warn"
}
},
+38 -38
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.5.2
version: 11.5.2
specifier: 11.13.1
version: 11.13.1
pnpm:
specifier: 11.5.2
version: 11.5.2
specifier: 11.13.1
version: 11.13.1
packages:
'@pnpm/exe@11.5.2':
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
'@pnpm/exe@11.13.1':
resolution: {integrity: sha512-P4euEK6lOFnd5oTHEc5M/HhvyF4XUhTnVsklEcM6rmY0QJxPD6xbT+u1+gskEIBp4nSRorz20IJQtAU1Nerggg==}
hasBin: true
'@pnpm/linux-arm64@11.5.2':
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
'@pnpm/linux-arm64@11.13.1':
resolution: {integrity: sha512-wB8zloqrYrudPyuA5qbuTCnJGe4eETPwqOjoPjoyyyvA4zFI5XfLpxgqOOcaY5UJBoqzckcGpRVDwhSRfsQ/6A==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.5.2':
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
'@pnpm/linux-x64@11.13.1':
resolution: {integrity: sha512-A+wnEvzfWEvanXiwww3tnOPmtjPSrrf5tOP6vk8+K0BRFEe/Df0oPytm2nWgGcn5iwPnqtr1Btkof913McnSPA==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.5.2':
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
'@pnpm/linuxstatic-arm64@11.13.1':
resolution: {integrity: sha512-k4t65VeqRX4COMFe45TF58CVmCpmAsKZShaR1HobmUeleo98mWTctggKolrA2MHcVUeSS+12yB5Urb3uDazhmw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.5.2':
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
'@pnpm/linuxstatic-x64@11.13.1':
resolution: {integrity: sha512-A65GqPzwCl0bAMk3kRWfbjSRBm5RRaqR2oMxV/9AYZrwO0X9yEfngbLBISCPHjt6/Qe4nH7DFemyhy6yODYwEw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.5.2':
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
'@pnpm/macos-arm64@11.13.1':
resolution: {integrity: sha512-MJvOtyGOWSfBoqdVEfAH8ljmHs13mt82k/UxN4f+q7koDxJRR2n4Nie6Og6RwbnbaubCz0Fh2bTeL1+MxDSFpA==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.5.2':
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
'@pnpm/win-arm64@11.13.1':
resolution: {integrity: sha512-kl/g1cCKOJPe4HntspyrAJW0LRco0UHnVfxHSspezo4Zj4AanJAZ8WzLqfa6/w3lBSKHTEN4x0pb3m4J7B7Vpw==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.5.2':
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
'@pnpm/win-x64@11.13.1':
resolution: {integrity: sha512-Bcb14NeBlbHS2Gq1qr8VnCiAz5eC1lYzXOls7zH0bnV0Taaj4/xyfm0HVO4dn9R2TQtVtz1qnBZHHL9PDFumqQ==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.5.2:
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
pnpm@11.13.1:
resolution: {integrity: sha512-svx2g7imUlQU59E+G6KMqt3elr9m7FQL+ut+cCuB8+C+TR8pXt9/n+A5Z0Co3ORQnFgt33mJH0VD/qMtN2RfJQ==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.5.2':
'@pnpm/exe@11.13.1':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.5.2
'@pnpm/linux-x64': 11.5.2
'@pnpm/linuxstatic-arm64': 11.5.2
'@pnpm/linuxstatic-x64': 11.5.2
'@pnpm/macos-arm64': 11.5.2
'@pnpm/win-arm64': 11.5.2
'@pnpm/win-x64': 11.5.2
'@pnpm/linux-arm64': 11.13.1
'@pnpm/linux-x64': 11.13.1
'@pnpm/linuxstatic-arm64': 11.13.1
'@pnpm/linuxstatic-x64': 11.13.1
'@pnpm/macos-arm64': 11.13.1
'@pnpm/win-arm64': 11.13.1
'@pnpm/win-x64': 11.13.1
'@pnpm/linux-arm64@11.5.2':
'@pnpm/linux-arm64@11.13.1':
optional: true
'@pnpm/linux-x64@11.5.2':
'@pnpm/linux-x64@11.13.1':
optional: true
'@pnpm/linuxstatic-arm64@11.5.2':
'@pnpm/linuxstatic-arm64@11.13.1':
optional: true
'@pnpm/linuxstatic-x64@11.5.2':
'@pnpm/linuxstatic-x64@11.13.1':
optional: true
'@pnpm/macos-arm64@11.5.2':
'@pnpm/macos-arm64@11.13.1':
optional: true
'@pnpm/win-arm64@11.5.2':
'@pnpm/win-arm64@11.13.1':
optional: true
'@pnpm/win-x64@11.5.2':
'@pnpm/win-x64@11.13.1':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.5.2: {}
pnpm@11.13.1: {}
---
lockfileVersion: '9.0'
+1
View File
@@ -328,6 +328,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/interests", server.WebGenericNoindex)
e.GET("/settings/about", server.WebGenericNoindex)
e.GET("/settings/notifications", server.WebGenericNoindex)
e.GET("/settings/notifications/activity", server.WebGenericNoindex)
e.GET("/sys/debug", server.WebGenericNoindex)
e.GET("/sys/debug-mod", server.WebGenericNoindex)
e.GET("/sys/log", server.WebGenericNoindex)
+1 -1
View File
@@ -29,7 +29,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
+6 -5
View File
@@ -17,6 +17,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etly
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
@@ -40,10 +42,10 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -186,7 +188,6 @@ github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+8 -3
View File
@@ -1,11 +1,16 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "xyz.blueskyweb.app",
"sha256_cert_fingerprints":
["C1:4D:3C:6B:B5:D6:D9:AE:CF:C5:0B:BC:C1:9B:29:6D:D4:E6:87:46:36:D5:4C:1A:64:1C:14:08:BF:7E:F9:62", "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C"]
"sha256_cert_fingerprints": [
"C1:4D:3C:6B:B5:D6:D9:AE:CF:C5:0B:BC:C1:9B:29:6D:D4:E6:87:46:36:D5:4C:1A:64:1C:14:08:BF:7E:F9:62",
"FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C"
]
}
}
]
+3 -1
View File
@@ -11,7 +11,9 @@
"two_letters_code": {
"pt": "pt-PT",
"pt-BR": "pt-BR",
"en-CA": "en-CA",
"en-GB": "en-GB",
"fr-CA": "fr-CA",
"zh-CN": "zh-CN",
"zh-TW": "zh-TW",
"zh-HK": "zh-HK"
@@ -19,4 +21,4 @@
}
}
]
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts",
"start:external": "NODE_ENV=development PGPORT=5433 PGHOST=localhost PGUSER=pg PGPASSWORD=password PGDATABASE=postgres DB_POSTGRES_URL=postgresql://pg:password@127.0.0.1:5433/postgres REDIS_HOST=127.0.0.1:6380 node ./mock-server.ts"
},
"dependencies": {
"@atproto/api": "^0.20.22",
+16 -5
View File
@@ -52,7 +52,18 @@ have already been deployed for this release, incremement the branch name e.g.
Cherry pick in the commits that need to be deployed on top of the most recent
release or OTA.
### 5. Manually set build numbers
### 5. Pull translations
Since translators may have added new strings, and positions within the code may
have shifted, it's typically best to pull the latest translations.
Run this and commit the result as the last commit on the OTA branch.
```sh
pnpm intl:release
```
### 6. Manually set build numbers
Log in to the EAS CLI with `eas login` and manually set the build numbers to the
values you found in **Step 1**.
@@ -75,7 +86,7 @@ values you found in **Step 1**.
👉 **Save the previous values,** in this case `1011` and `641`, so you can reset
them after the OTA completes.
### 6. Run the GitHub actions
### 7. Run the GitHub actions
You'll need to run two separate actions: one to deploy the iOS/Android OTA
itself, and one to build the web Docker container.
@@ -102,13 +113,13 @@ and run the action.
| ----- | --- |
| Select your OTA branch `1.x.0-ota-x` and click "Run workflow" | ![workflow](./img/web_action.png) |
### 7. Deploy web
### 8. Deploy web
Once the web Docker container build finishes, go to your `1.x.0-ota-x` branch,
copy the most recent commit hash. Post this hash in `#ops-deploys` and request
someone with web deploy access deploy the built container.
### 8. Confirm successful deployment
### 9. Confirm successful deployment
In about five minutes, the new deployment should be deployed and devices will
begin downloading and installing in the background.
@@ -119,7 +130,7 @@ build from your device and re-install from the App Store. Then, you'll need to:
- Quit and reopen the app
- Check the `Settings > About` page and confirm the hash matches the most recent hash on your OTA branch
### 9. Reset build numbers
### 10. Reset build numbers
Grab the build numbers you saved in **Step 5** and reverse the EAS CLI commands
to reset the build numbers.
+43
View File
@@ -29,6 +29,49 @@ adb reverse tcp:3000 tcp:3000
- In a second tab, run `pnpm e2e:build`
- In a third tab, run `pnpm e2e:run __e2e__`
## Nightly Maestro CI
The `Nightly Maestro E2E` GitHub Actions workflow runs every day at 04:00 UTC
and can also be started from the Actions tab with **Run workflow**. It runs iOS
and Android concurrently, but each platform runs all of `__e2e__/config.yml`
sequentially on one explicitly selected simulator or emulator. The flows share a
stateful mock-server manager, so the suite must not be sharded.
The jobs run Maestro CLI 2.6.1 locally on GitHub Actions; Maestro Cloud is not
used. iOS runs on `macos-26-xlarge` with Xcode 26.4. Android runs on
`Linux-x64-32core`. Both use Java 17 and the Node and pnpm versions declared in
`package.json`. The iOS job selects an iPhone 17 simulator running iOS 26.5;
Android directly provisions and boots a Pixel 6 AVD with the API 35 Google APIs
x86_64 image using the Android SDK command-line tools.
Both development clients use the `e2e` EAS profile and the same reusable local
EAS build action as the release build workflows; the resulting simulator app
and APK are installed directly on the selected devices.
The mock-server manager listens on host port 1986 and creates test services on
port 3000. Metro listens on 8081. Android reverses ports 3000 and 8081 into the
emulator; port 1986 remains host-side because Maestro JavaScript calls it from
the runner. Android uses the existing Docker Compose PostgreSQL 14 and Redis 7
services on ports 5433 and 6380. GitHub-hosted macOS cannot run nested Docker
virtualization, so iOS provisions ephemeral native PostgreSQL 14.x and Redis
7.4.7 on those same ports and starts `pnpm --dir dev-env start:external`.
Each platform uploads a `nightly-e2e-<platform>-<run-id>` artifact for 14 days.
It contains JUnit at `report.xml`, Maestro screenshots, videos, command metadata
and `maestro.log` under `maestro/`, plus Metro, native build, mock-server, service,
dependency, and translation logs. The workflow always uploads what was captured,
including when setup or the native build fails before Maestro starts.
Add the repository secret `E2E_FAILURES_SLACK_WEBHOOK` before enabling the
schedule. The aggregation job runs even when either platform fails and posts one
detailed Slack notification containing both job statuses, failed flow details or
the failed setup phase, the commit and workflow links, and links to both artifact
sets. Successful runs do not post to Slack.
Before relying on the schedule, manually dispatch the workflow and verify both
platforms against live Metro and `dev-env`, Android localhost routing, artifact
uploads on success and failure, one Slack message for a forced failure, and no
Slack message for an all-green run.
## Using Flashlight for Performance Testing
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
+11
View File
@@ -21,6 +21,17 @@
"EXPO_PUBLIC_ENV": "production"
}
},
"e2e": {
"extends": "development",
"android": {
"buildType": "apk"
},
"env": {
"EXPO_PUBLIC_ENV": "e2e",
"NODE_ENV": "development",
"RN_SRC_EXT": "e2e.ts,e2e.tsx"
}
},
"preview": {
"extends": "base",
"distribution": "internal",
+2
View File
@@ -13,11 +13,13 @@ export default defineConfig({
'de',
'el',
'en-GB',
'en-CA',
'eo',
'es',
'eu',
'fi',
'fr',
'fr-CA',
'fy',
'ga',
'gd',
+25 -19
View File
@@ -6,7 +6,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
// Views
private var sheetVc: SheetViewController?
private var innerView: UIView?
private var touchHandler: RCTTouchHandler?
private var touchHandler: RCTSurfaceTouchHandler?
// Native content height observation (eliminates JS bridge round-trip)
private var contentHeightObservation: NSKeyValueObservation?
@@ -81,33 +81,37 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
required init (appContext: AppContext? = nil) {
super.init(appContext: appContext)
self.maxHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
self.touchHandler = RCTSurfaceTouchHandler()
SheetManager.shared.add(self)
}
deinit {
self.destroy()
}
override func mountChildComponentView(
_ childComponentView: UIView,
index: Int
) {
self.innerView = childComponentView
touchHandler?.attach(to: childComponentView)
}
override func unmountChildComponentView(
_ childComponentView: UIView,
index: Int
) {
touchHandler?.detach(from: childComponentView)
// We don't want this view to actually get added to the tree, so we'll simply store it for adding
// to the SheetViewController
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
self.touchHandler?.attach(to: subview)
self.innerView = subview
childComponentView.removeFromSuperview()
if self.innerView === childComponentView {
self.innerView = nil
}
}
// We'll grab the content height from here so we know the initial detent to set
override func layoutSubviews() {
super.layoutSubviews()
guard let innerView = self.innerView else {
return
}
if innerView.subviews.count != 1 {
return
}
self.present()
}
@@ -117,7 +121,10 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
self.isClosing = false
self.isOpen = false
self.sheetVc = nil
self.touchHandler?.detach(from: self.innerView)
if let innerView = self.innerView {
self.touchHandler?.detach(from: innerView)
}
self.touchHandler = nil
self.innerView = nil
SheetManager.shared.remove(self)
@@ -146,8 +153,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
if #available(iOS 26.0, *),
let tag = self.sourceViewTag,
let bridge = self.appContext?.reactBridge,
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
let sourceView = self.appContext?.findView(withTag: tag, ofType: UIView.self) {
sheetVc.preferredTransition = .zoom { _ in
return sourceView
}
@@ -0,0 +1,3 @@
#if __has_include(<React/RCTSurfaceTouchHandler.h>)
#import <React/RCTSurfaceTouchHandler.h>
#endif
@@ -146,6 +146,7 @@ function BottomSheetNativeComponentInner({
const insets = useSafeAreaInsets()
const cornerRadius = rest.cornerRadius ?? 0
const {height: screenHeight} = useWindowDimensions()
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
// sigh... on older Android versions, screenHeight does not include safe area insets
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
@@ -182,7 +183,7 @@ function BottomSheetNativeComponentInner({
]}>
<View
onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}>
style={isHeightConstrained ? {flex: 1} : undefined}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
@@ -1,5 +1,14 @@
import {Component} from 'react'
import {type BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheetNativeComponent(_: BottomSheetViewProps) {
throw new Error('BottomSheetNativeComponent is not available on web')
export class BottomSheetNativeComponent extends Component<BottomSheetViewProps> {
/*
* Native sheets do not exist on web; there is nothing to dismiss.
*/
static dismissAll = async () => {}
render(): never {
throw new Error('BottomSheetNativeComponent is not available on web')
}
}
@@ -6,7 +6,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
/*
* Promise-returning for parity with Android, whose native referrer API only
* exposes a promise.
*/
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
return Promise.resolve(getReferrerInfoSync())
}
function getReferrerInfoSync(): ReferrerInfo | null {
const referrer = SharedPrefs.getString('referrer')
if (referrer) {
SharedPrefs.removeValue('referrer')
@@ -5,6 +5,6 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
throw new NotImplementedError()
}
@@ -7,7 +7,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
/*
* Promise-returning for parity with Android, whose native referrer API only
* exposes a promise.
*/
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
return Promise.resolve(getReferrerInfoSync())
}
function getReferrerInfoSync(): ReferrerInfo | null {
if (
Platform.OS === 'web' &&
// for ssr
-116
View File
@@ -1,116 +0,0 @@
# expo-scroll-forwarder
An Expo native module that forwards scroll gestures from a UIView to a UIScrollView on iOS. This enables custom scroll behaviors by allowing a non-scrollable view to control a scrollable view's scroll position.
## What It Does
This module solves a specific interaction problem: allowing a fixed header or overlay view to respond to scroll gestures and forward them to an underlying scroll view. The primary use case in the Bluesky app is the profile screen, where the profile header sits above a scrollable content area and can be dragged to scroll the content below it.
Key behaviors:
- Captures pan gestures on a wrapper view and translates them to scroll offsets on a target scroll view
- Implements physics-based deceleration animations that match native scroll behavior
- Supports pull-to-refresh interactions with haptic feedback
- Prevents gesture conflicts with iOS swipe-back navigation by only activating on vertical pans
- Provides rubber-band damping when scrolling past content bounds
## Architecture
The module consists of three main parts:
### 1. Native iOS Implementation (Swift)
**ExpoScrollForwarderView.swift** - The core native view component that:
- Attaches a UIPanGestureRecognizer to intercept scroll gestures
- Finds and references the target RCTScrollView using its React Native tag
- Implements custom scroll physics including velocity-based decay animation
- Manages gesture recognizer delegation to prevent conflicts with system gestures
- Handles pull-to-refresh activation at -130pt scroll offset with haptic feedback
**ExpoScrollForwarderModule.swift** - The Expo module definition that:
- Registers the view component with Expo
- Exposes the `scrollViewTag` prop to specify which scroll view to control
### 2. TypeScript Interface
**ExpoScrollForwarderView.tsx** - Platform-specific implementations:
- **iOS (.ios.tsx)**: Wraps the native view manager from expo-modules-core
- **Default (.tsx)**: No-op wrapper that just renders children (for Android/Web compatibility)
**ExpoScrollForwarder.types.ts** - TypeScript type definitions:
- `scrollViewTag`: The React Native tag of the scroll view to control
- `children`: The content to render (typically a header component)
### 3. Module Configuration
**expo-module.config.json** - Declares iOS-only platform support
**ExpoScrollForwarder.podspec** - CocoaPods specification for iOS dependency management
## Usage
```tsx
import {ExpoScrollForwarderView} from 'expo-scroll-forwarder'
function ProfileScreen() {
const scrollViewTag = useRef(null)
return (
<View>
<ExpoScrollForwarderView scrollViewTag={scrollViewTag.current}>
<ProfileHeader />
</ExpoScrollForwarderView>
<ScrollView ref={scrollViewTag}>
{/* Scrollable content */}
</ScrollView>
</View>
)
}
```
The `scrollViewTag` prop must be the React Native tag (numeric identifier) of the target scroll view. The module uses this to locate the native UIScrollView instance.
## Platform Support
- **iOS**: Full native implementation with custom scroll physics
- **Android**: No-op wrapper (renders children without scroll forwarding)
- **Web**: No-op wrapper (renders children without scroll forwarding)
The module is designed to enhance iOS UX while gracefully degrading on other platforms.
## Key Implementation Details
### Gesture Recognition
- Only activates when pan velocity is more vertical than horizontal (`abs(velocity.y) > abs(velocity.x)`)
- Delegates to UIGestureRecognizerDelegate to prevent simultaneous recognition with navigation swipe-back
- Adds tap/long-press recognizers to the scroll view to cancel ongoing animations
### Scroll Physics
- Implements custom decay animation at 120fps using a Timer
- Velocity decay factor: 0.9875 per frame
- Velocity clamped to +/- 5000 points/second
- Rubber-band damping: offsets below 0 are reduced by 55%
- Animation stops when velocity drops below 5 points/second
### Pull-to-Refresh
- Triggers at -130pt scroll offset
- Provides haptic feedback (UIImpactFeedbackGenerator, light style)
- Calls refresh control via `RCTRefreshControl.forwarderBeginRefreshing()`
### Scroll View Management
- Dynamically finds scroll view using `AppContext.findView(withTag:ofType:)`
- Properly cleans up gesture recognizers when switching between scroll views
- Maintains references to both the scroll view and its refresh control
## Files Overview
| File | Purpose |
|------|---------|
| `ios/ExpoScrollForwarderView.swift` | Native iOS view implementation with gesture handling and scroll physics |
| `ios/ExpoScrollForwarderModule.swift` | Expo module registration and prop definitions |
| `ios/ExpoScrollForwarder.podspec` | CocoaPods dependency specification |
| `src/ExpoScrollForwarderView.ios.tsx` | TypeScript wrapper for iOS native view |
| `src/ExpoScrollForwarderView.tsx` | Default no-op implementation for other platforms |
| `src/ExpoScrollForwarder.types.ts` | TypeScript type definitions |
| `index.ts` | Module entry point |
| `expo-module.config.json` | Expo module configuration |
@@ -1,6 +0,0 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["ExpoScrollForwarderModule"]
}
}
-1
View File
@@ -1 +0,0 @@
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
@@ -1,21 +0,0 @@
Pod::Spec.new do |s|
s.name = 'ExpoScrollForwarder'
s.version = '1.0.0'
s.summary = 'Forward scroll gesture from UIView to UIScrollView'
s.description = 'Forward scroll gesture from UIView to UIScrollView'
s.author = 'bluesky-social'
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -1,13 +0,0 @@
import ExpoModulesCore
public class ExpoScrollForwarderModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoScrollForwarder")
View(ExpoScrollForwarderView.self) {
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
view.scrollViewTag = prop
}
}
}
}
@@ -1,214 +0,0 @@
import ExpoModulesCore
import React
// This view will be used as a native component. Make sure to inherit from `ExpoView`
// to apply the proper styling (e.g. border radius and shadows).
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
var scrollViewTag: Int? {
didSet {
self.tryFindScrollView()
}
}
private var rctScrollView: RCTScrollView?
private var rctRefreshCtrl: RCTRefreshControl?
private var cancelGestureRecognizers: [UIGestureRecognizer]?
private var animTimer: Timer?
private var initialOffset: CGFloat = 0.0
private var didImpact: Bool = false
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
pg.delegate = self
self.addGestureRecognizer(pg)
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
tg.isEnabled = false
tg.delegate = self
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
lpg.minimumPressDuration = 0.01
lpg.isEnabled = false
lpg.delegate = self
self.cancelGestureRecognizers = [lpg, tg]
}
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
return false
}
return true
}
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
// interfere with the native swipe back gesture.
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
return true
}
let velocity = gestureRecognizer.velocity(in: self)
return abs(velocity.y) > abs(velocity.x)
}
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
// recognizer for this one.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
self.stopTimer()
}
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
self.stopTimer()
}
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
return
}
let translation = sender.translation(in: self).y
if sender.state == .began {
if sv.contentOffset.y < 0 {
sv.contentOffset.y = 0
}
self.initialOffset = sv.contentOffset.y
}
if sender.state == .changed {
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
if sv.contentOffset.y <= -130, !didImpact {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
self.didImpact = true
}
}
if sender.state == .ended {
let velocity = sender.velocity(in: self).y
self.didImpact = false
if sv.contentOffset.y <= -130 {
self.rctRefreshCtrl?.forwarderBeginRefreshing()
return
}
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
// scroll view
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
return
}
self.startDecayAnimation(translation, velocity)
}
}
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
guard let sv = self.rctScrollView?.scrollView else {
return
}
var velocity = velocity
self.enableCancelGestureRecognizers()
if velocity > 0 {
velocity = min(velocity, 5000)
} else {
velocity = max(velocity, -5000)
}
var animTranslation = -translation
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in
velocity *= 0.9875
animTranslation = (-velocity / 120) + animTranslation
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
if nextOffset <= 0 {
if self.initialOffset <= 1 {
self.scrollToOffset(0)
} else {
sv.contentOffset.y = 0
}
self.stopTimer()
return
} else {
sv.contentOffset.y = nextOffset
}
if abs(velocity) < 5 {
self.stopTimer()
}
}
}
func dampenOffset(_ offset: CGFloat) -> CGFloat {
if offset < 0 {
return offset - (offset * 0.55)
}
return offset
}
func tryFindScrollView() {
guard let scrollViewTag = scrollViewTag else {
return
}
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
// Otherwise we might end up with duplicates when we switch back to that scrollview.
self.removeCancelGestureRecognizers()
self.rctScrollView = self.appContext?
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
self.addCancelGestureRecognizers()
}
func addCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
}
}
func removeCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
}
}
func enableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = true
}
}
func disableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = false
}
}
func scrollToOffset(_ offset: Int, animated: Bool = true) {
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
}
func stopTimer() {
self.disableCancelGestureRecognizers()
self.animTimer?.invalidate()
self.animTimer = nil
}
}
@@ -1,4 +0,0 @@
export interface ExpoScrollForwarderViewProps {
scrollViewTag: number | null
children: React.ReactNode
}
@@ -1,13 +0,0 @@
import {requireNativeViewManager} from 'expo-modules-core'
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
requireNativeViewManager('ExpoScrollForwarder')
export function ExpoScrollForwarderView({
children,
...rest
}: ExpoScrollForwarderViewProps) {
return <NativeView {...rest}>{children}</NativeView>
}
@@ -1,7 +0,0 @@
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
export function ExpoScrollForwarderView({
children,
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
return children
}
@@ -0,0 +1,20 @@
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
Pod::Spec.new do |s|
s.name = "ScrollForwarder"
s.version = package["version"]
s.summary = package["description"]
s.homepage = package["homepage"]
s.license = package["license"]
s.authors = package["author"]
s.platforms = { :ios => min_ios_version_supported }
s.source = { :git => ".git", :tag => "#{s.version}" }
s.source_files = "ios/**/*.{h,m,mm,cpp}"
s.private_header_files = "ios/**/*.h"
install_modules_dependencies(s)
end
@@ -0,0 +1,14 @@
#import <React/RCTViewComponentView.h>
#import <UIKit/UIKit.h>
#ifndef ScrollForwarderViewNativeComponent_h
#define ScrollForwarderViewNativeComponent_h
NS_ASSUME_NONNULL_BEGIN
@interface ScrollForwarderView : RCTViewComponentView
@end
NS_ASSUME_NONNULL_END
#endif /* ScrollForwarderViewNativeComponent_h */
@@ -0,0 +1,411 @@
#import "ScrollForwarderView.h"
#import <React/RCTEnhancedScrollView.h>
#import <React/RCTScrollViewComponentView.h>
#import <react/renderer/components/ScrollForwarderViewSpec/ComponentDescriptors.h>
#import <react/renderer/components/ScrollForwarderViewSpec/EventEmitters.h>
#import <react/renderer/components/ScrollForwarderViewSpec/Props.h>
#import <react/renderer/components/ScrollForwarderViewSpec/RCTComponentViewHelpers.h>
#import "RCTFabricComponentsPlugins.h"
using namespace facebook::react;
// How far down a pull needs to be to trigger a refresh
static const CGFloat kPullThreshold = 130.0;
static const CGFloat kDampingFactor = 0.55;
// The top speed that free scrolling can have
static const CGFloat kMaxVelocity = 5000.0;
// Free scrolling decay. This seems to be close to the default iOS value
static const CGFloat kVelocityDecay = 0.9875;
// What scroll release velocity will actually trigger free scrolling
static const CGFloat kMinimumVelocity = 5.0;
@interface ScrollForwarderView () <RCTScrollForwarderViewViewProtocol, UIGestureRecognizerDelegate>
@end
@implementation ScrollForwarderView {
NSArray<UIGestureRecognizer *> * _cancelGestureRecognizers;
RCTScrollViewComponentView * _svcv;
CGPoint _initialOffset;
CADisplayLink * _displayLink;
CGFloat _currentVelocity;
CGFloat _accumulatedTranslation;
bool _didImpact;
}
+ (ComponentDescriptorProvider)componentDescriptorProvider
{
return concreteComponentDescriptorProvider<ScrollForwarderViewComponentDescriptor>();
}
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
static const auto defaultProps = std::make_shared<const ScrollForwarderViewProps>();
_props = defaultProps;
UIPanGestureRecognizer *pg = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
pg.delegate = self;
pg.cancelsTouchesInView = false;
[self addGestureRecognizer:pg];
UITapGestureRecognizer *tg = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
[tg setEnabled:false];
tg.delegate = self;
UILongPressGestureRecognizer *lpg = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
[lpg setMinimumPressDuration:0.01];
[lpg setEnabled:false];
lpg.delegate = self;
NSArray<UIGestureRecognizer *> *cancelGestureRecognizers = [NSArray arrayWithObjects:lpg, tg, nil];
_cancelGestureRecognizers = cancelGestureRecognizers;
}
return self;
}
// MARK: - Lifecycle
- (void)dealloc
{
[self stopAnimation];
[self removeCancelGestureRecognizers];
_svcv = nil;
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
gr.delegate = nil;
}
}
- (void)prepareForRecycle
{
[super prepareForRecycle];
[self stopAnimation];
[self removeCancelGestureRecognizers];
_svcv = nil;
}
// MARK: - Props
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
{
const auto &oldViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(_props);
const auto &newViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(props);
if (oldViewProps.scrollViewTag != newViewProps.scrollViewTag) {
[self tryFindScrollView];
}
if (oldViewProps.refreshing != newViewProps.refreshing) {
if (!newViewProps.refreshing) {
[self endRefreshing];
}
}
[super updateProps:props oldProps:oldProps];
}
// MARK: - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
if ([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]] && [otherGestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
return NO;
}
return YES;
}
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
if (![gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
return YES;
}
UIPanGestureRecognizer *pg = (UIPanGestureRecognizer *)gestureRecognizer;
CGPoint velocity = [pg velocityInView:self];
return fabs(velocity.y) > fabs(velocity.x);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
[self stopAnimation];
[super touchesBegan:touches withEvent:event];
}
// MARK: - Scroll Forwarding
- (void)removeCancelGestureRecognizers
{
if (!_svcv) return;
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
[_svcv.scrollView removeGestureRecognizer:gr];
}
}
- (void)addCancelGestureRecognizers
{
if (!_svcv) return;
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
[_svcv.scrollView addGestureRecognizer:gr];
}
}
- (void)enableCancelGestureRecognizers
{
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
[gr setEnabled:true];
}
}
- (void)disableCancelGestureRecognizers
{
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
[gr setEnabled:false];
}
}
- (void)scrollToOffset:(CGPoint)offset animated:(bool)animated
{
if (!_svcv) return;
[_svcv scrollToOffset:offset animated:animated];
}
- (void)stopAnimation
{
[self disableCancelGestureRecognizers];
[_displayLink invalidate];
_displayLink = nil;
}
- (void)handlePan:(UIPanGestureRecognizer *)gesture {
if (!_svcv) return;
UIScrollView *sv = _svcv.scrollView;
CGPoint translation = [gesture translationInView:self];
if (gesture.state == UIGestureRecognizerStateBegan) {
_didImpact = false;
if (sv.contentOffset.y < 0) {
CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0);
sv.contentOffset = newOffset;
}
_initialOffset = sv.contentOffset;
}
if (gesture.state == UIGestureRecognizerStateChanged) {
CGPoint newOffset = CGPointMake(sv.contentOffset.x, [self dampenOffset:(-translation.y + _initialOffset.y)]);
sv.contentOffset = newOffset;
if (sv.contentOffset.y <= -kPullThreshold && !_didImpact) {
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
[generator impactOccurred];
_didImpact = true;
}
}
if (gesture.state == UIGestureRecognizerStateEnded) {
CGPoint velocity = [gesture velocityInView:self];
if (sv.contentOffset.y <= -kPullThreshold) {
[self refresh];
return;
}
if (sv.contentOffset.y < 0) {
CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0);
[self scrollToOffset:newOffset animated:true];
return;
}
if (abs(velocity.y) < 250 && sv.contentOffset.y >= 0) {
return;
}
[self startDecayWithInitialTranslation:translation.y velocity:velocity.y];
}
}
- (CGFloat)dampenOffset:(CGFloat)offset
{
if (offset < 0) {
return offset - (offset * kDampingFactor);
}
return offset;
}
- (void)handleTap:(UITapGestureRecognizer *)gesture {
[self stopAnimation];
}
- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture {
[self stopAnimation];
}
- (void)startDecayWithInitialTranslation:(CGFloat)translation velocity:(CGFloat)startVelocity
{
if (!_svcv) return;
startVelocity = MAX(-kMaxVelocity, MIN(kMaxVelocity, startVelocity));
_currentVelocity = startVelocity;
_accumulatedTranslation = -translation;
[self enableCancelGestureRecognizers];
[_displayLink invalidate];
CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDecayStep:)];
link.preferredFramesPerSecond = 60;
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
_displayLink = link;
}
- (void)handleDecayStep:(CADisplayLink *)link
{
_currentVelocity *= kVelocityDecay;
CGFloat delta = -_currentVelocity / link.preferredFramesPerSecond;
_accumulatedTranslation += delta;
CGFloat rawY = _accumulatedTranslation + _initialOffset.y;
CGFloat nextY = rawY > 0 ? rawY : 0;
CGPoint newOffset = CGPointMake(
_svcv.scrollView.contentOffset.x,
nextY
);
_svcv.scrollView.contentOffset = newOffset;
if (fabs(_currentVelocity) < kMinimumVelocity || nextY <= 0) {
[link invalidate];
_displayLink = nil;
[self disableCancelGestureRecognizers];
}
}
/*
* We use this component on profile pages. The screne consists of a header component, a scrollview with buttons to
* switch between profile tabs, and a pager view (RNCPagerViewComponentView). Both the header and the tab bar are
* inside the same RCTViewComponentView. The view heirarchy looks something like this:
* - RCTViewComponentView
* -- RNCPagerViewComponentView
* ----- (Many views deep) RCTScrollViewComponentView
* ------ RCTEnhancedScrollView
* -- RCTViewComponentView
* --- RCTViewComponentView
* ---- ScrollForwarderView
* --- RCTScrollViewComponentView
* ---- RCTEnhancedScrollView
*
* We want to find that RCTScrollViewComponentView inside of the RNCPagerViewComponentView. To achieve this, we can
* use self.superview.superview.superview to get to the root RCTViewComponentView, find the RNCPagerViewComponentView,
* then iterate through that view's subviews until we find a RCTScrollViewComponentView.
*
* This isn't great, because if we reorder the React components, we'll need to update this logic. There's probably
* an easier way to achieve this, similar to how we used to do it in Paper (ie, get the scroll view's tag and find that),
* but this also comes with some benefits, eg being able to reduce a lot of the logic in the JS code and just find the
* scrollview when subviews change.
*/
- (void)tryFindScrollView
{
[self removeCancelGestureRecognizers];
// The root RCTViewComponentView
UIView *rootView = self.superview.superview.superview;
UIView *pagerView;
NSString *targetClsName = @"RNCPagerViewComponentView";
Class targetCls = NSClassFromString(targetClsName);
for (UIView *subview in rootView.subviews) {
if ([subview isKindOfClass:targetCls]) {
pagerView = subview;
break;
}
}
if (!pagerView) return;
RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:pagerView];
if (!svcv) return;
_svcv = svcv;
[self addCancelGestureRecognizers];
}
- (RCTScrollViewComponentView *)findRTCScrollViewComponentViewInView:(UIView *)view
{
for (UIView *subview in view.subviews) {
if ([subview isKindOfClass:[RCTScrollViewComponentView class]]) {
RCTScrollViewComponentView *svcv = (RCTScrollViewComponentView *) subview;
return svcv;
}
RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:subview];
if (svcv) return svcv;
}
return nil;
}
- (UIRefreshControl *)refreshContorl
{
if (!_svcv) return nil;
return _svcv.scrollView.refreshControl;
}
- (void)refresh
{
__weak ScrollForwarderView *weakSelf = self;
[_svcv.scrollView.refreshControl beginRefreshing];
[UIView animateWithDuration:0.3
delay:0
options:UIViewAnimationOptionBeginFromCurrentState
animations:^(void) {
if (!weakSelf) return;
__strong ScrollForwarderView *self = weakSelf;
// Whenever we call this method, the scrollview will always be at a position of
// -130 or less. Scrolling back to -80 simulates the default behavior of RCTRefreshControl
[self->_svcv.scrollView setContentOffset:CGPointMake(0, -65)];
}
completion:^(__unused BOOL finished) {
__strong ScrollForwarderView *self = weakSelf;
if (self->_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::ScrollForwarderViewEventEmitter>(self->_eventEmitter)
->onRefresh(facebook::react::ScrollForwarderViewEventEmitter::OnRefresh{});
}
}
];
}
- (void)endRefreshing
{
UIRefreshControl *rc = [self refreshContorl];
CGPoint newOffset = CGPointMake(_svcv.scrollView.contentOffset.x, 0.0);
[self scrollToOffset:newOffset animated:true];
[rc endRefreshing];
}
Class<RCTComponentViewProtocol> ScrollForwarderViewCls(void)
{
return ScrollForwarderView.class;
}
@end
@@ -0,0 +1,27 @@
{
"name": "react-native-scroll-forwarder",
"version": "0.0.0",
"description": "Scroll forwarder module for Bluesky profile headers",
"main": "src/index",
"codegenConfig": {
"name": "ScrollForwarderViewSpec",
"type": "all",
"jsSrcsDir": "src",
"ios": {
"componentProvider": {
"ScrollForwarderView": "ScrollForwarderView"
}
}
},
"peerDependencies": {
"react-native": "*"
},
"author": "Hailey <me@haileyok.com>",
"license": "MIT",
"homepage": "#readme",
"create-react-native-library": {
"languages": "kotlin-objc",
"type": "fabric-view",
"version": "0.50.2"
}
}
@@ -0,0 +1,12 @@
import {
default as NativeScrollForwarderView,
type NativeProps,
} from './ScrollForwarderViewNativeComponent'
export function ScrollForwarderView({children, ...rest}: NativeProps) {
return (
<NativeScrollForwarderView {...rest} style={{flex: 1}}>
{children}
</NativeScrollForwarderView>
)
}
@@ -0,0 +1,5 @@
import {type NativeProps} from './ScrollForwarderViewNativeComponent'
export function ScrollForwarderView({children}: NativeProps) {
return children
}
@@ -0,0 +1,15 @@
import {
codegenNativeComponent,
type CodegenTypes,
type ViewProps,
} from 'react-native'
type OnRefreshEvent = {}
export interface NativeProps extends ViewProps {
scrollViewTag: CodegenTypes.Int32 | null
refreshing?: boolean
onRefresh?: CodegenTypes.BubblingEventHandler<OnRefreshEvent>
}
export default codegenNativeComponent<NativeProps>('ScrollForwarderView')
@@ -0,0 +1,2 @@
export {ScrollForwarderView} from './ScrollForwarderView'
export * from './ScrollForwarderViewNativeComponent'
@@ -0,0 +1 @@
export {ScrollForwarderView} from './ScrollForwarderView'
+1 -82
View File
@@ -93,17 +93,6 @@
"count": 2
}
},
"src/analytics/features/index.ts": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 1
},
"typescript/require-await": {
"count": 1
}
},
"src/analytics/identifiers/session.test.ts": {
"typescript/no-unsafe-call": {
"count": 9
@@ -112,11 +101,6 @@
"count": 9
}
},
"src/analytics/metadata.ts": {
"typescript/no-explicit-any": {
"count": 1
}
},
"src/analytics/metrics/client.test.ts": {
"typescript/no-explicit-any": {
"count": 1
@@ -260,14 +244,6 @@
"count": 3
}
},
"src/components/Post/Embed/ImageEmbed.tsx": {
"typescript/no-explicit-any": {
"count": 2
},
"typescript/no-floating-promises": {
"count": 1
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"typescript/no-floating-promises": {
"count": 2
@@ -298,7 +274,7 @@
"count": 2
},
"typescript/no-misused-promises": {
"count": 2
"count": 1
}
},
"src/components/PostControls/DiscoverDebug.tsx": {
@@ -306,19 +282,6 @@
"count": 1
}
},
"src/components/PostControls/ShareMenu/ShareMenuItems.tsx": {
"typescript/no-floating-promises": {
"count": 3
},
"typescript/no-misused-promises": {
"count": 1
}
},
"src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx": {
"typescript/no-floating-promises": {
"count": 3
}
},
"src/components/PostControls/ShareMenu/index.tsx": {
"typescript/no-floating-promises": {
"count": 1
@@ -882,17 +845,6 @@
"count": 1
}
},
"src/lib/strings/errors.ts": {
"typescript/no-explicit-any": {
"count": 1
},
"typescript/no-unsafe-call": {
"count": 14
},
"typescript/no-unsafe-member-access": {
"count": 14
}
},
"src/lib/useGetEmojis/getEmojis.ts": {
"typescript/require-await": {
"count": 1
@@ -1082,11 +1034,6 @@
"count": 1
}
},
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
"typescript/no-misused-promises": {
"count": 5
}
},
"src/screens/ProfileList/FeedSection.tsx": {
"typescript/no-floating-promises": {
"count": 1
@@ -1433,11 +1380,6 @@
"count": 2
}
},
"src/state/queries/feed.ts": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/state/queries/handle.ts": {
"typescript/no-floating-promises": {
"count": 1
@@ -1644,14 +1586,6 @@
"count": 1
}
},
"src/view/com/composer/text-input/TextInput.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 1
}
},
"src/view/com/composer/text-input/TextInput.web.tsx": {
"typescript/no-misused-promises": {
"count": 1
@@ -1791,11 +1725,6 @@
"count": 2
}
},
"src/view/com/posts/PostFeed.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/PostFeedErrorMessage.tsx": {
"typescript/no-explicit-any": {
"count": 1
@@ -1807,16 +1736,6 @@
"count": 9
}
},
"src/view/com/profile/ProfileFollowers.tsx": {
"typescript/no-misused-promises": {
"count": 2
}
},
"src/view/com/profile/ProfileFollows.tsx": {
"typescript/no-misused-promises": {
"count": 2
}
},
"src/view/com/util/EmptyState.tsx": {
"typescript/no-explicit-any": {
"count": 1
+28 -21
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.128.0",
"version": "1.130.0",
"private": true,
"engines": {
"node": ">=24.18.0"
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.11.0",
"version": "11.13.1",
"onFail": "warn"
},
"runtime": {
@@ -62,11 +62,14 @@
"lint": "oxlint --quiet src modules",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web",
"typecheck:ios": "tsc --project ./tsconfig.check.ios.json",
"typecheck:android": "tsc --project ./tsconfig.check.android.json",
"typecheck:web": "tsc --project ./tsconfig.check.web.json",
"e2e:mock-server": "cd dev-env && pnpm start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=development RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=development RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=development RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
"e2e:run": "maestro test",
"perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
@@ -93,19 +96,19 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.25",
"@atproto/common-web": "0.5.3",
"@atproto/syntax": "0.6.4",
"@atproto/api": "0.20.33",
"@atproto/common-web": "0.5.6",
"@atproto/syntax": "0.7.2",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
"@bsky.app/alf": "^0.1.15",
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.3.1",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/react-native-uitextview": "^2.4.1",
"@bsky.app/sift": "^0.3.9",
"@bsky.app/tapper": "^0.6.1",
"@bsky.app/video": "0.3.6",
@@ -155,9 +158,10 @@
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "54.0.34",
"expo": "54.0.35",
"expo-age-range": "0.2.18",
"expo-application": "~7.0.8",
"expo-asset": "~12.0.13",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10",
@@ -167,7 +171,7 @@
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-glass-effect": "55.0.8",
"expo-glass-effect": "0.1.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
@@ -181,7 +185,7 @@
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.17",
"expo-paste-input": "^0.2.1",
"expo-privacy-sensitive": "^0.1.0",
"expo-privacy-sensitive": "^0.2.0",
"expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.8",
"expo-sms": "^14.0.7",
@@ -223,21 +227,23 @@
"react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.2.3",
"react-native-edge-to-edge": "^1.8.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-gesture-handler": "~2.30.0",
"react-native-keyboard-controller": "^1.21.8",
"react-native-mmkv": "^3.3.3",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "3.19.1",
"react-native-reanimated": "~4.3.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "4.24.0",
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
"react-native-svg": "15.12.1",
"react-native-uitextview": "^1.4.0",
"react-native-uuid": "^2.0.3",
"react-native-view-shot": "^4.0.3",
"react-native-web": "^0.21.0",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "^13.15.0",
"react-native-worklets": "0.8.3",
"react-remove-scroll-bar": "^2.3.8",
"react-responsive": "^10.0.1",
"react-textarea-autosize": "^8.5.3",
@@ -270,6 +276,7 @@
"@types/psl": "1.1.1",
"@types/react": "^19.1.17",
"@types/react-dom": "^19.1.11",
"@typescript/native": "npm:typescript@^7.0.2",
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
@@ -288,13 +295,13 @@
"jest-junit": "^16.0.0",
"lint-staged": "^17.0.8",
"oxlint": "^1.73.0",
"oxlint-tsgolint": "^0.24.0",
"oxlint-tsgolint": "^7.0.2001",
"prettier": "^3.8.3",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"svgo": "^3.3.2",
"svgo": "^4.0.2",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"webpack-bundle-analyzer": "^4.10.1"
},
"jest": {
-60
View File
@@ -1,60 +0,0 @@
diff --git a/ios/GlassContainer.swift b/ios/GlassContainer.swift
index 61fb67cdfa2022f57524ddde05096067055e9ee6..b2d111ef8a724b8e7d4404f3d40efce3bd6fbb6d 100644
--- a/ios/GlassContainer.swift
+++ b/ios/GlassContainer.swift
@@ -1,6 +1,7 @@
// Copyright 2022-present 650 Industries. All rights reserved.
import ExpoModulesCore
+import React
public final class GlassContainer: ExpoView {
private var containerEffect: Any?
@@ -46,11 +47,19 @@ public final class GlassContainer: ExpoView {
}
}
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
+ // Paper: redirect children into the container effect's contentView
+ public override func didUpdateReactSubviews() {
+ for subview in self.reactSubviews() {
+ containerEffectView.contentView.addSubview(subview)
+ }
+ }
+
+ // Fabric: redirect children into the container effect's contentView
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
containerEffectView.contentView.insertSubview(childComponentView, at: index)
}
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
childComponentView.removeFromSuperview()
}
}
diff --git a/ios/GlassView.swift b/ios/GlassView.swift
index 35cd8f320009a9e28fdbb2f55cc409734ba98f40..9587306b6fac3455ab27a5289eb62a711aa50c03 100644
--- a/ios/GlassView.swift
+++ b/ios/GlassView.swift
@@ -271,11 +271,19 @@ public final class GlassView: ExpoView {
#endif
}
}
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
+ // Paper: redirect children into the glass effect's contentView
+ public override func didUpdateReactSubviews() {
+ for subview in self.reactSubviews() {
+ glassEffectView.contentView.addSubview(subview)
+ }
+ }
+
+ // Fabric: redirect children into the glass effect's contentView
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
glassEffectView.contentView.insertSubview(childComponentView, at: index)
}
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
childComponentView.removeFromSuperview()
}
}
@@ -1,3 +0,0 @@
# expo-glass-effect patch
Patches in support for Expo SDK 54. Please delete when we update Expo
@@ -15,3 +15,17 @@ index 480746eb7acfbe86f67547d9e1de7a5be4d5faf2..13d30cb547195993dfcb4005cc0d248d
#endif
-
diff --git a/package.json b/package.json
index 469386dc2e81ded8994818dd4340409da1396488..36460e4e303ae56534c4d389471fc827433c7028 100644
--- a/package.json
+++ b/package.json
@@ -70,9 +70,6 @@
"ios": {
"componentProvider": {
"RNDatePicker": "RNDatePicker"
- },
- "modulesProvider": {
- "RNDatePicker": "RNDatePickerManager"
}
}
}
@@ -0,0 +1,31 @@
diff --git a/apple/RNGestureHandler.mm b/apple/RNGestureHandler.mm
index c4f760c41a9965245edcfa5e7cb781f8d2c7b66a..bf7d1fb092e22cbcedf787e606876e533879f810 100644
--- a/apple/RNGestureHandler.mm
+++ b/apple/RNGestureHandler.mm
@@ -470,15 +470,19 @@ + (RNGestureHandler *)findGestureHandlerByRecognizer:(UIGestureRecognizer *)reco
// We may try to extract "DummyGestureHandler" in case when "otherGestureRecognizer" belongs to
// a native view being wrapped with "NativeViewGestureHandler"
- RNGHUIView *reactView = recognizer.view;
- while (reactView != nil && reactView.reactTag == nil) {
- reactView = reactView.superview;
- }
+ RNGHUIView *view = recognizer.view;
+ while (view != nil) {
+ for (UIGestureRecognizer *candidateRecognizer in view.gestureRecognizers) {
+ if ([candidateRecognizer isKindOfClass:[RNDummyGestureRecognizer class]]) {
+ return candidateRecognizer.gestureHandler;
+ }
+ }
- for (UIGestureRecognizer *recognizer in reactView.gestureRecognizers) {
- if ([recognizer isKindOfClass:[RNDummyGestureRecognizer class]]) {
- return recognizer.gestureHandler;
+ if ([view isKindOfClass:[RCTViewComponentView class]]) {
+ return nil;
}
+
+ view = view.superview;
}
return nil;
@@ -0,0 +1,5 @@
# react-native-gesture-handler.patch
Updated `findGestureHandlerByRecognizer:` in `apple/RNGestureHandler.mm` to the version from RN GH 2.32.0
This fixes `UIContextMenuInteraction` from `ExpoBlueskyPeekMenuView.swift`. https://github.com/software-mansion/react-native-gesture-handler/commit/fba4dcc06d71dce08b10b2afc738a2af5b01e86a

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