Compare commits

...
Author SHA1 Message Date
buzzert 20a310a4b1 ci: use Ruby 3.1 for TestFlight upload
TestFlight / testflight (push) Failing after 46s
2026-07-11 12:01:27 -07:00
buzzert b6859706db ios: simplify CI signing keychain setup
TestFlight / testflight (push) Failing after 25s
2026-07-11 11:55:04 -07:00
buzzert ea148839d3 Revert "ios: pass match keychain to codesign"
This reverts commit a0e410155d.
2026-07-11 11:32:00 -07:00
buzzert a0e410155d ios: pass match keychain to codesign
TestFlight / testflight (push) Failing after 55s
2026-07-11 11:29:10 -07:00
buzzert b5a5d21767 docs: document iOS release workflow 2026-07-11 11:17:05 -07:00
buzzert 0ad96f4f99 ios: center transcript content on iPad
TestFlight / testflight (push) Failing after 52s
2026-07-11 11:15:01 -07:00
buzzert b0c2b74c16 remove temporary keychain (unneeded)
TestFlight / testflight (push) Successful in 1m46s
2026-07-06 09:25:24 -07:00
buzzert 14d2d8cb28 fix spacing between lists and paragraphs 2026-07-05 19:54:34 -07:00
buzzert 03836bbc4c Raise Anthropic default max tokens 2026-07-05 11:53:17 -07:00
buzzert 76ce62025a bullets in ul 2026-07-04 20:22:13 -07:00
buzzert b15473d24e ios: stamp release version into XcodeGen spec
TestFlight / testflight (push) Successful in 1m43s
2026-06-26 01:29:54 -07:00
buzzert a512a65844 ios: accept release tag namespace in Fastfile
TestFlight / testflight (push) Successful in 1m55s
2026-06-26 01:18:02 -07:00
buzzert 207b44f67f ios: ci: actually release/ios/v*.
TestFlight / testflight (push) Failing after 19s
2026-06-26 01:15:08 -07:00
buzzert c5ccd212c9 ios: ci: testflight only on release/v*
TestFlight / testflight (push) Successful in 1m55s
2026-06-26 01:08:47 -07:00
buzzertandClaude Opus 4.8 ee990dde5d ios: simplify Fastfile signing now that the runner has a real session
With SessionCreate on the runner's launchd job, standard fastlane keychain
handling works, so drop the debugging-era workarounds: the manual
default-keychain / list-keychains search-list juggling, the login-keychain
restoration in cleanup, the verify_ci_signing re-unlock/partition/find-identity
step (match already imports the cert and sets the key partition list), and the
CODE_SIGN_KEYCHAIN / OTHER_CODE_SIGN_FLAGS xcargs. CI signing is now a single
create_keychain + match. No behavior change; validated end-to-end on TestFlight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:52:36 -07:00
buzzertandClaude Opus 4.8 036743fe41 ios: source TestFlight build number from CI run number
TestFlight / testflight (push) Successful in 1m53s
App Store Connect requires CFBundleVersion to be unique and strictly
increasing app-wide. latest_testflight_build_number could return a stale
value (it missed an existing build 13) and produced colliding build
numbers, 409-ing every upload. Use the monotonic Gitea run number
(github.run_number -> SYBIL_BUILD_NUMBER) instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:41:37 -07:00
buzzertandClaude Opus 4.8 4e7ea7ab68 ios: get TestFlight CI signing working
TestFlight / testflight (push) Failing after 57s
Replace the old testflight-release workflow with a single
`testflight.yml` Gitea Actions workflow and rework the fastlane `beta`
lane: match-based app-store signing into a disposable CI keychain,
XcodeGen project generation, version/build-number bumping, and upload
to TestFlight. Pin Ruby to 3.1.7 for the runner.

Squashes the iterative CI signing debugging history into one commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:28:27 -07:00
20 changed files with 381 additions and 429 deletions
-187
View File
@@ -1,187 +0,0 @@
name: TestFlight Release
on:
push:
tags:
- "release/v*.*.*"
permissions:
contents: write
jobs:
testflight:
runs-on: xcode
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate release tag
run: |
set -euo pipefail
tag_name="${GITHUB_REF#refs/tags/}"
if [[ ! "$tag_name" =~ ^release/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Release tag must match release/vN.N.N; got ${tag_name}" >&2
exit 1
fi
release_version="${tag_name#release/v}"
{
echo "TAG_NAME=${tag_name}"
echo "RELEASE_VERSION=${release_version}"
} >> "${GITHUB_ENV}"
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
- name: Install Ruby gems
working-directory: ios
run: bundle install
- name: Install release tools
run: |
set -euo pipefail
missing_tools=()
for tool in xcodegen jq; do
if ! command -v "${tool}" >/dev/null 2>&1; then
missing_tools+=("${tool}")
fi
done
if [[ "${#missing_tools[@]}" -eq 0 ]]; then
exit 0
fi
if ! command -v brew >/dev/null 2>&1; then
echo "Missing required tools: ${missing_tools[*]}; Homebrew is not available to install them" >&2
exit 1
fi
brew install "${missing_tools[@]}"
- name: Import code signing certificates
uses: Apple-Actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPSTORE_CERTIFICATES_FILE_BASE64 }}
p12-password: ${{ secrets.APPSTORE_CERTIFICATES_PASSWORD }}
- name: Create fastlane environment
working-directory: ios
env:
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
run: |
set -euo pipefail
: "${FASTLANE_USER:?FASTLANE_USER secret is required}"
: "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD:?FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD secret is required}"
{
printf 'FASTLANE_USER=%s\n' "${FASTLANE_USER}"
printf 'FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=%s\n' "${FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD}"
printf 'FASTLANE_SKIP_UPDATE_CHECK=1\n'
printf 'FASTLANE_HIDE_CHANGELOG=1\n'
} > .env
- name: Build and upload to TestFlight
working-directory: ios
env:
FASTLANE_DONT_STORE_PASSWORD: "1"
run: |
set -euo pipefail
SYBIL_VERSION_TAG="${TAG_NAME}" bundle exec fastlane ios beta
- name: Locate IPA
run: |
set -euo pipefail
ipa_path="$(find ios/build/fastlane -maxdepth 1 -type f -name '*.ipa' -print | sort | tail -n 1)"
if [[ -z "${ipa_path}" ]]; then
echo "No IPA found under ios/build/fastlane" >&2
exit 1
fi
{
echo "IPA_PATH=${ipa_path}"
echo "IPA_NAME=$(basename "${ipa_path}")"
} >> "${GITHUB_ENV}"
- name: Publish Gitea release asset
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_API_URL: ${{ github.api_url }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
api_url="${RELEASE_API_URL:-https://code.buzzert.dev/api/v1}"
repository="${RELEASE_REPOSITORY:-buzzert/Sybil-2}"
sha="${RELEASE_SHA:-${GITHUB_SHA:-}}"
release_name="Sybil v${RELEASE_VERSION}"
release_body="Automated TestFlight release for ${TAG_NAME}."
release_payload="$(jq -nc \
--arg tag "${TAG_NAME}" \
--arg name "${release_name}" \
--arg body "${release_body}" \
--arg target "${sha}" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false} +
(if $target == "" then {} else {target_commitish: $target} end)')"
response_file="$(mktemp)"
status="$(curl -sS -o "${response_file}" -w "%{http_code}" \
-X POST "${api_url}/repos/${repository}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
--data "${release_payload}")"
if [[ "${status}" == "201" ]]; then
release_id="$(jq -r '.id' "${response_file}")"
elif [[ "${status}" == "409" ]]; then
release_id="$(curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api_url}/repos/${repository}/releases?limit=100" |
jq -r --arg tag "${TAG_NAME}" '.[] | select(.tag_name == $tag) | .id' |
head -n 1)"
else
cat "${response_file}" >&2
exit 1
fi
if [[ -z "${release_id}" || "${release_id}" == "null" ]]; then
echo "Could not resolve Gitea release id for ${TAG_NAME}" >&2
exit 1
fi
existing_asset_id="$(curl -fsS \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api_url}/repos/${repository}/releases/${release_id}/assets" |
jq -r --arg name "${IPA_NAME}" '.[] | select(.name == $name) | .id' |
head -n 1)"
if [[ -n "${existing_asset_id}" && "${existing_asset_id}" != "null" ]]; then
curl -fsS -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${api_url}/repos/${repository}/releases/${release_id}/assets/${existing_asset_id}"
fi
asset_name="$(jq -rn --arg value "${IPA_NAME}" '$value | @uri')"
curl -fsS -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${IPA_PATH}" \
"${api_url}/repos/${repository}/releases/${release_id}/assets?name=${asset_name}" >/dev/null
echo "Published ${IPA_NAME} to ${release_name}"
+59
View File
@@ -0,0 +1,59 @@
name: TestFlight
on:
workflow_dispatch:
push:
tags:
- "release/ios/v*"
jobs:
testflight:
runs-on: macos-arm64
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1.7"
bundler-cache: true
working-directory: ios
- name: Install XcodeGen
run: |
set -euo pipefail
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
- name: Runner diagnostics
run: |
set -euo pipefail
whoami
printf 'HOME=%s\n' "$HOME"
security default-keychain -d user || true
security list-keychains -d user || true
- name: Upload to TestFlight
working-directory: ios
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
SYBIL_BUILD_NUMBER: ${{ github.run_number }}
FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
run: |
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
ruby --version
bundle exec fastlane ios beta
+2 -1
View File
@@ -1,2 +1,3 @@
.env
ios/fastlane/README.md
ios/fastlane/report.xml
+1
View File
@@ -285,6 +285,7 @@ Behavior notes:
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
+1
View File
@@ -64,6 +64,7 @@ Notes:
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
Persisted chat streams with a `chatId` are backend-owned active runs:
+8 -4
View File
@@ -1,14 +1,18 @@
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
FASTLANE_TEAM_ID=DQQH5H6GBD
FASTLANE_USER=you@example.com
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1
SYBIL_APP_STORE_APPLE_ID=6759442828
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
SYBIL_PROVISIONING_PROFILE_UUID=
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
SYBIL_SIGNING_CERTIFICATE_ID=
SYBIL_SIGNING_KEYCHAIN=
# Optional App Store Connect API key settings for non-interactive upload and
# TestFlight build-number lookup.
# App Store Connect API key settings for TestFlight upload and signing setup.
APP_STORE_CONNECT_API_KEY_ID=
APP_STORE_CONNECT_API_ISSUER_ID=
APP_STORE_CONNECT_API_KEY_PATH=
+6
View File
@@ -21,6 +21,12 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- To choose a screenshot path, run `just screenshot path=build/name.png`.
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
## Release Workflow
- iOS release tags use the annotated tag namespace `release/ios/vX.Y.Z`; increment from the latest existing `release/ios/v*` tag.
- Tag message convention is `ios: X.Y.Z`, for example `git tag -a release/ios/v1.13.5 -m "ios: 1.13.5"`.
- Push the release commit and tag together with `git push origin <branch> release/ios/vX.Y.Z`.
- Fastlane derives the marketing version from the release tag and stamps `ios/Apps/Sybil/project.yml` during CI, so do not manually bump `MARKETING_VERSION` for normal tagged releases unless explicitly requested.
## App Structure
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
- Shared iOS app code lives in Swift package:
+7 -1
View File
@@ -24,7 +24,7 @@ targets:
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: "1.10"
MARKETING_VERSION: "1.13.2"
CURRENT_PROJECT_VERSION: 11
INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
@@ -32,6 +32,12 @@ targets:
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
configs:
Release:
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: Apple Distribution
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
schemes:
Sybil:
+1 -1
View File
@@ -1,3 +1,3 @@
source "https://rubygems.org"
gem "fastlane", "~> 2.227"
gem "fastlane"
+1 -1
View File
@@ -225,7 +225,7 @@ PLATFORMS
ruby
DEPENDENCIES
fastlane (~> 2.227)
fastlane
BUNDLED WITH
2.5.23
@@ -58,7 +58,8 @@ struct SybilChatTranscriptView: View {
.frame(height: 18 + bottomContentInset)
.id(bottomAnchorID)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 14)
.padding(.top, 18 + topContentInset)
}
@@ -98,7 +98,8 @@ struct SybilSearchResultsView: View {
.foregroundStyle(SybilTheme.danger)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 14)
.padding(.top, 20 + topContentInset)
.padding(.bottom, 20 + bottomContentInset)
@@ -64,6 +64,10 @@ extension Font {
}
}
enum SybilLayout {
static let webContentMaxWidth: CGFloat = 896
}
enum SybilTheme {
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
-9
View File
@@ -1,9 +0,0 @@
require "dotenv"
Dotenv.load(File.expand_path("../.env", __dir__))
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
-32
View File
@@ -1,32 +0,0 @@
# TestFlight Release CI
Gitea Actions publishes iOS releases from tags that match:
```sh
release/vN.N.N
```
For example:
```sh
git tag release/v1.10.0
git push origin release/v1.10.0
```
The release job runs on the `xcode` runner label, imports the signing p12 with
`Apple-Actions/import-codesign-certs`, builds and uploads the app with fastlane,
then creates or updates the matching Gitea release with the generated IPA as an
asset.
Required repository secrets:
```text
APPSTORE_CERTIFICATES_FILE_BASE64
APPSTORE_CERTIFICATES_PASSWORD
FASTLANE_USER
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
```
The workflow uses Gitea's built-in `GITEA_TOKEN` for release creation and asset
upload, with `contents: write` permissions. In Gitea this covers release asset
publication.
+124 -146
View File
@@ -1,177 +1,155 @@
require "dotenv"
require "open3"
require "shellwords"
require "yaml"
Dotenv.load(File.expand_path("../.env", __dir__))
default_platform(:ios)
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
APP_IDENTIFIER = "net.buzzert.sybil2"
SCHEME = "Sybil"
TEAM_ID = "DQQH5H6GBD"
PROFILE_NAME = "Sybil AppStore CI"
IOS_ROOT = File.expand_path("..", __dir__)
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
SCHEME = "Sybil"
TARGET = "SybilApp"
APP_PROJECT_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
def present?(value)
!value.to_s.strip.empty?
end
def capture(command)
stdout, stderr, status = Open3.capture3(command)
return stdout.strip if status.success?
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
end
def app_project_settings
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
end
def local_marketing_version
app_project_settings.fetch("MARKETING_VERSION").to_s
end
def local_build_number
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
end
def normalize_version_tag(tag)
version = tag.to_s.strip.sub(%r{\Arelease/}, "").sub(/\Av/, "")
unless version.match?(/\A\d+\.\d+\.\d+\z/)
UI.user_error!("Release tag #{tag.inspect} must look like release/v1.10.0")
end
version
def ci?
present?(ENV["CI"])
end
def release_version
tag = ENV["SYBIL_VERSION_TAG"]
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
normalize_version_tag(tag)
end
tag = ENV["GITHUB_REF_NAME"] if !present?(tag)
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
match = tag.to_s.match(%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z})
def xcode_build_setting(key, value)
"#{key}=#{value.to_s.shellescape}"
end
def app_store_connect_key_options
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
return nil unless present?(key_id) && present?(issuer_id)
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
if present?(key_path)
{
key_id: key_id,
issuer_id: issuer_id,
key_filepath: key_path
}
elsif present?(key_content)
{
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
}
unless match
UI.user_error!("Release tag must look like release/ios/v1.2.3; got #{tag.inspect}")
end
match[1]
end
# App Store Connect requires CFBundleVersion to be unique and strictly
# increasing app-wide (not just per marketing version), so we derive it from
# the monotonic CI run number rather than querying TestFlight (that query can
# lag behind builds still processing and hand back a colliding value).
def build_number
value = present?(ENV["SYBIL_BUILD_NUMBER"]) ? ENV["SYBIL_BUILD_NUMBER"] : ENV["GITHUB_RUN_NUMBER"]
unless value.to_s.match?(/\A\d+\z/)
UI.user_error!("Build number must come from SYBIL_BUILD_NUMBER/GITHUB_RUN_NUMBER; got #{value.inspect}")
end
value.to_i
end
def stamp_marketing_version(version)
contents = File.read(APP_PROJECT_SPEC)
updated = contents.sub(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
if updated == contents
UI.user_error!("Could not find MARKETING_VERSION in #{APP_PROJECT_SPEC}")
end
File.write(APP_PROJECT_SPEC, updated)
end
platform :ios do
desc "Show the version Fastlane will stamp into the next TestFlight archive"
lane :version do
UI.message("Git tag version: #{release_version}")
UI.message("Checked-in app version: #{local_marketing_version}")
UI.message("Checked-in build number: #{local_build_number}")
private_lane :app_store_api_key do
app_store_connect_api_key(
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"),
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"),
is_key_content_base64: true
)
end
desc "Build Sybil and upload it to TestFlight"
lane :beta do
version = release_version
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
api_key = nil
# CI uses a throwaway keychain that is deleted after the lane exits.
private_lane :prepare_ci_keychain do
next nil unless ci?
if app_store_connect_key_options
api_key = app_store_connect_api_key(app_store_connect_key_options)
end
run_token = "#{Time.now.to_i}_#{Process.pid}"
keychain_name = "fastlane_ci_#{run_token}"
unless present?(build_number)
build_number = (local_build_number + 1).to_s
if api_key
begin
latest = latest_testflight_build_number(
app_identifier: APP_IDENTIFIER,
version: version,
api_key: api_key,
initial_build_number: local_build_number
).to_i
build_number = [latest + 1, local_build_number + 1].max.to_s
rescue StandardError => e
UI.important("Could not look up TestFlight build number: #{e.message}")
UI.important("Using checked-in build number + 1: #{build_number}")
end
end
end
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
xcode_args = [
"-allowProvisioningUpdates",
xcode_build_setting("MARKETING_VERSION", version),
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
].join(" ")
ipa_path = build_app(
project: PROJECT_FILE,
scheme: SCHEME,
clean: true,
sdk: "iphoneos",
export_method: "app-store",
output_directory: File.join(IOS_ROOT, "build/fastlane"),
output_name: "Sybil-#{version}-#{build_number}.ipa",
xcargs: xcode_args,
export_xcargs: "-allowProvisioningUpdates",
export_options: {
method: "app-store-connect",
destination: "export",
signingStyle: "automatic",
teamID: TEAM_ID,
manageAppVersionAndBuildNumber: false,
uploadSymbols: true,
stripSwiftSymbols: true
}
setup_ci(
force: true,
keychain_name: keychain_name,
timeout: 7_200
)
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
keychain_name
end
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
private_lane :sync_signing do |options|
match(
type: "appstore",
readonly: options.fetch(:readonly),
app_identifier: APP_IDENTIFIER,
team_id: TEAM_ID,
profile_name: PROFILE_NAME,
git_url: ENV.fetch("MATCH_GIT_URL"),
git_branch: "master",
git_full_name: "Sybil Release Bot",
git_user_email: "james.magahern@me.com",
api_key: options.fetch(:api_key)
)
end
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
sh([
"xcrun altool",
"--upload-package #{ipa_path.shellescape}",
"--platform ios",
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
"--bundle-id #{APP_IDENTIFIER.shellescape}",
"--bundle-version #{build_number.shellescape}",
"--bundle-short-version-string #{version.shellescape}",
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
"--password @env:ITMS_TRANSPORTER_PASSWORD",
"--show-progress"
].join(" "))
desc "Create or update match signing assets"
lane :setup_signing do
sync_signing(api_key: app_store_api_key, readonly: false)
end
desc "Build and upload to TestFlight"
lane :beta do
ci_keychain_name = nil
begin
ci_keychain_name = prepare_ci_keychain
api_key = app_store_api_key
version = release_version
stamp_marketing_version(version)
sh("xcodegen", "--spec", PROJECT_SPEC)
increment_version_number(version_number: version, xcodeproj: PROJECT_FILE)
increment_build_number(build_number: build_number, xcodeproj: PROJECT_FILE)
sync_signing(api_key: api_key, readonly: true)
sh("security find-identity -v -p codesigning") if ci_keychain_name
build_app(
project: PROJECT_FILE,
scheme: SCHEME,
export_method: "app-store",
codesigning_identity: "Apple Distribution",
xcargs: [
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
].join(" "),
export_options: {
signingStyle: "manual",
teamID: TEAM_ID,
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_NAME
}
}
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true
)
ensure
delete_keychain(name: ci_keychain_name) if ci_keychain_name
end
end
end
-40
View File
@@ -1,40 +0,0 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## iOS
### ios version
```sh
[bundle exec] fastlane ios version
```
Show the version Fastlane will stamp into the next TestFlight archive
### ios beta
```sh
[bundle exec] fastlane ios beta
```
Build Sybil and upload it to TestFlight
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
+45 -4
View File
@@ -28,6 +28,45 @@ import type { ChatMessage } from "../types.js";
const INTERNAL_CORRECTION =
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
const DEFAULT_ANTHROPIC_MAX_TOKENS = 128_000;
const MODEL_MAX_TOKENS_CACHE_MS = 24 * 60 * 60 * 1000;
const modelMaxTokensCache = new Map<string, { maxTokens: number; expiresAt: number }>();
function readMaxTokens(value: unknown) {
return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined;
}
function getModelInfoMaxTokens(modelInfo: any) {
return readMaxTokens(modelInfo?.max_tokens) ?? readMaxTokens(modelInfo?.maxTokens);
}
async function getMessagesMaxTokens(params: ToolAwareCompletionParams) {
if (params.maxTokens) return params.maxTokens;
const cached = modelMaxTokensCache.get(params.model);
if (cached && cached.expiresAt > Date.now()) return cached.maxTokens;
try {
const retrieve = params.client?.models?.retrieve;
if (typeof retrieve === "function") {
const modelInfo = await retrieve.call(params.client.models, params.model);
const maxTokens = getModelInfoMaxTokens(modelInfo);
if (maxTokens) {
modelMaxTokensCache.set(params.model, {
maxTokens,
expiresAt: Date.now() + MODEL_MAX_TOKENS_CACHE_MS,
});
return maxTokens;
}
}
} catch {
// Fall back to the documented max for Claude Opus 4.8 and related high-output models.
}
return DEFAULT_ANTHROPIC_MAX_TOKENS;
}
function toTools(tools: any[]) {
return tools
.map((tool) => {
@@ -160,11 +199,12 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
const enabledTools = getEnabledChatTools(params);
const maxTokens = await getMessagesMaxTokens(params);
if (!enabledTools.length) {
const response = await params.client.messages.create({
model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
max_tokens: params.maxTokens ?? 1024,
max_tokens: maxTokens,
temperature: params.temperature,
messages: buildBaseMessages(params),
} as any);
@@ -192,7 +232,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
const response = await params.client.messages.create({
model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
max_tokens: params.maxTokens ?? 1024,
max_tokens: maxTokens,
temperature: params.temperature,
messages: conversation,
tools: toTools(enabledTools),
@@ -248,6 +288,7 @@ export async function completeWithMessagesApi(params: ToolAwareCompletionParams)
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
const enabledTools = getEnabledChatTools(params);
const maxTokens = await getMessagesMaxTokens(params);
if (!enabledTools.length) {
const rawResponses: unknown[] = [];
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
@@ -259,7 +300,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
const stream = await params.client.messages.create({
model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
max_tokens: params.maxTokens ?? 1024,
max_tokens: maxTokens,
temperature: params.temperature,
messages: buildBaseMessages(params),
stream: true,
@@ -315,7 +356,7 @@ export async function* streamWithMessagesApi(params: ToolAwareCompletionParams):
const stream = await params.client.messages.create({
model: params.model,
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
max_tokens: params.maxTokens ?? 1024,
max_tokens: maxTokens,
temperature: params.temperature,
messages: conversation,
tools: toTools(enabledTools),
+88
View File
@@ -140,6 +140,94 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
});
test("Messages API defaults max_tokens to the Anthropic model maximum", async () => {
let requestBody: any = null;
let retrievedModel: string | null = null;
const client = {
models: {
retrieve: async (model: string) => {
retrievedModel = model;
return { id: model, max_tokens: 128000 };
},
},
messages: {
create: async (body: any) => {
requestBody = body;
return {
content: [{ type: "text", text: "Done" }],
usage: { input_tokens: 1, output_tokens: 1 },
};
},
},
};
const result = await completeWithMessagesApi({
client: client as any,
model: "claude-max-default-test",
messages: [{ role: "user", content: "Say done" }],
});
assert.equal(retrievedModel, "claude-max-default-test");
assert.equal(requestBody?.max_tokens, 128000);
assert.equal(result.text, "Done");
});
test("Messages API preserves explicit maxTokens", async () => {
let requestBody: any = null;
let didRetrieveModel = false;
const client = {
models: {
retrieve: async () => {
didRetrieveModel = true;
return { max_tokens: 128000 };
},
},
messages: {
create: async (body: any) => {
requestBody = body;
return streamFrom([
{
type: "message_start",
message: {
usage: { input_tokens: 1, output_tokens: 0 },
},
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Done" },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: null },
usage: { output_tokens: 1 },
},
{ type: "message_stop" },
]);
},
},
};
const events = await collectEvents(
streamWithMessagesApi({
client: client as any,
model: "claude-explicit-max-test",
messages: [{ role: "user", content: "Say done" }],
maxTokens: 4096,
})
);
assert.equal(didRetrieveModel, false);
assert.equal(requestBody?.max_tokens, 4096);
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
});
test("fetch_url sends browser-like navigation headers", async () => {
const originalFetch = globalThis.fetch;
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
+30 -1
View File
@@ -286,6 +286,14 @@ textarea {
word-break: break-word;
}
.md-content > :first-child {
margin-top: 0;
}
.md-content > :last-child {
margin-bottom: 0;
}
.md-table-scroll {
max-width: 100%;
margin: 0.35rem 0 1rem;
@@ -384,7 +392,8 @@ textarea {
.md-content ul,
.md-content ol {
margin-top: 0.65rem;
margin-top: 0.85rem;
margin-bottom: 0.85rem;
margin-left: 0;
padding-left: 0;
list-style: none;
@@ -396,6 +405,26 @@ textarea {
padding-left: 1.35rem;
}
.md-content ul > li {
position: relative;
padding-left: 1.1rem;
}
.md-content ul > li::before {
content: "";
position: absolute;
left: 0;
top: 0.76em;
width: 0.36rem;
height: 0.36rem;
border-radius: 9999px;
background: hsl(188 86% 62%);
box-shadow:
0 0 0 2px hsl(188 86% 62% / 0.12),
0 0 10px hsl(188 86% 62% / 0.42);
transform: translateY(-50%);
}
.md-content li + li {
margin-top: 0.3rem;
}