Compare commits

...

19 Commits

Author SHA1 Message Date
69f50064a3 ci: restore Ruby PATH export for fastlane step
All checks were successful
TestFlight / testflight (push) Successful in 1m58s
act_runner does not carry setup-ruby's PATH changes into later steps,
so without this the step runs the toolcache default Ruby (3.3.11) and
bundler cannot find the gems installed for 3.1.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:04:47 -07:00
e7b81f24bd ci: put CI keychain first in codesign search list
Some checks failed
TestFlight / testflight (push) Failing after 19s
codesign resolves signing identities through the user keychain search
list (first match wins) and ignores --keychain for the lookup. This
runner hosts another project whose keychain holds the same Apple
Distribution identity, so when that keychain is locked, codesign fails
with errSecInternalComponent no matter how correctly our own keychain
is set up. Prepend the fresh CI keychain to the search list for the
build and always delete it afterward, which restores the original list.

Also drop the runner-diagnostics step, the GitHub-hosted Ruby PATH
export, and the duplicate CODE_SIGN_IDENTITY param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:01:42 -07:00
20a310a4b1 ci: use Ruby 3.1 for TestFlight upload
Some checks failed
TestFlight / testflight (push) Failing after 46s
2026-07-11 12:01:27 -07:00
b6859706db ios: simplify CI signing keychain setup
Some checks failed
TestFlight / testflight (push) Failing after 25s
2026-07-11 11:55:04 -07:00
ea148839d3 Revert "ios: pass match keychain to codesign"
This reverts commit a0e410155d.
2026-07-11 11:32:00 -07:00
a0e410155d ios: pass match keychain to codesign
Some checks failed
TestFlight / testflight (push) Failing after 55s
2026-07-11 11:29:10 -07:00
b5a5d21767 docs: document iOS release workflow 2026-07-11 11:17:05 -07:00
0ad96f4f99 ios: center transcript content on iPad
Some checks failed
TestFlight / testflight (push) Failing after 52s
2026-07-11 11:15:01 -07:00
b0c2b74c16 remove temporary keychain (unneeded)
All checks were successful
TestFlight / testflight (push) Successful in 1m46s
2026-07-06 09:25:24 -07:00
14d2d8cb28 fix spacing between lists and paragraphs 2026-07-05 19:54:34 -07:00
03836bbc4c Raise Anthropic default max tokens 2026-07-05 11:53:17 -07:00
76ce62025a bullets in ul 2026-07-04 20:22:13 -07:00
b15473d24e ios: stamp release version into XcodeGen spec
All checks were successful
TestFlight / testflight (push) Successful in 1m43s
2026-06-26 01:29:54 -07:00
a512a65844 ios: accept release tag namespace in Fastfile
All checks were successful
TestFlight / testflight (push) Successful in 1m55s
2026-06-26 01:18:02 -07:00
207b44f67f ios: ci: actually release/ios/v*.
Some checks failed
TestFlight / testflight (push) Failing after 19s
2026-06-26 01:15:08 -07:00
c5ccd212c9 ios: ci: testflight only on release/v*
All checks were successful
TestFlight / testflight (push) Successful in 1m55s
2026-06-26 01:08:47 -07:00
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
036743fe41 ios: source TestFlight build number from CI run number
All checks were successful
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
4e7ea7ab68 ios: get TestFlight CI signing working
Some checks failed
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 368 additions and 422 deletions

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}"

View File

@@ -0,0 +1,44 @@
name: TestFlight
on:
workflow_dispatch:
push:
tags:
- "release/ios/v*"
jobs:
testflight:
runs-on: macos-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.1.7"
bundler-cache: true
working-directory: ios
- name: Install XcodeGen
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
- 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"
# act_runner does not propagate setup-ruby's PATH changes into later
# steps, so put the selected Ruby back on PATH or `bundle` resolves to
# the toolcache default and misses the gems installed above.
run: |
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
bundle exec fastlane ios beta

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.env
ios/fastlane/README.md
ios/fastlane/report.xml

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`.

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:

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=

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:

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:

View File

@@ -1,3 +1,3 @@
source "https://rubygems.org"
gem "fastlane", "~> 2.227"
gem "fastlane"

View File

@@ -225,7 +225,7 @@ PLATFORMS
ruby
DEPENDENCIES
fastlane (~> 2.227)
fastlane
BUNDLED WITH
2.5.23

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)

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?

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.

View File

@@ -1,177 +1,164 @@
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"
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db")
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"
# CI signs headlessly, so match needs a fresh unlocked keychain to import
# into. codesign resolves identities through the user keychain *search list*
# (first match wins; the --keychain flag does not restrict the lookup), and
# other projects' keychains on this runner hold the same identity but are
# usually locked — so ours must come first. delete_keychain in the beta
# lane's ensure removes both the keychain and its search-list entry, which
# also keeps our (later locked) copy from shadowing those other projects.
private_lane :prepare_ci_keychain do
next unless ci?
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH)
create_keychain(
name: CI_KEYCHAIN_NAME,
password: CI_KEYCHAIN_PASSWORD,
unlock: true,
timeout: 3600,
add_to_search_list: false
)
others = sh("security list-keychains -d user", log: false)
.scan(/"([^"]+)"/)
.flatten
.reject { |path| path.include?(CI_KEYCHAIN_NAME) }
sh("security list-keychains -d user -s #{([CI_KEYCHAIN_DB_PATH] + others).shelljoin}")
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
end
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
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
prepare_ci_keychain
api_key = app_store_api_key
version = release_version
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
api_key = nil
stamp_marketing_version(version)
sh("xcodegen", "--spec", PROJECT_SPEC)
if app_store_connect_key_options
api_key = app_store_connect_api_key(app_store_connect_key_options)
end
increment_version_number(version_number: version, xcodeproj: PROJECT_FILE)
increment_build_number(build_number: build_number, xcodeproj: PROJECT_FILE)
unless present?(build_number)
build_number = (local_build_number + 1).to_s
sync_signing(api_key: api_key, readonly: true)
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(
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",
xcargs: [
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
].join(" "),
export_options: {
method: "app-store-connect",
destination: "export",
signingStyle: "automatic",
signingStyle: "manual",
teamID: TEAM_ID,
manageAppVersionAndBuildNumber: false,
uploadSymbols: true,
stripSwiftSymbols: true
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_NAME
}
}
)
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)
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)
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(" "))
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true
)
ensure
delete_keychain(name: CI_KEYCHAIN_NAME) if ci? && File.file?(CI_KEYCHAIN_DB_PATH)
end
end

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).

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),

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 }> = [];

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;
}