build(ios): attach app review notes PDF

This commit is contained in:
joshavant
2026-06-23 19:18:07 -05:00
parent c70accc86f
commit b93eeceac0
7 changed files with 401 additions and 13 deletions

View File

@@ -68,9 +68,9 @@ Release behavior:
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
- `pnpm ios:release:upload` generates App Store screenshots, uploads release notes, and attaches `apps/ios/APP-REVIEW-NOTES.md` as a rendered PDF before archiving and uploading the IPA.
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
- App Review submission is manual in App Store Connect. The release lane uploads a build, public metadata, and the App Review PDF attachment, but it does not submit for review or upload the App Store Connect `Notes` field.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- `apps/ios/version.json` is the pinned iOS release version source.
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.
@@ -178,7 +178,7 @@ pnpm ios:release:upload
- verifies synced iOS versioning artifacts
- resolves the next App Store Connect build number for that short version
- generates deterministic App Store screenshots
- uploads release notes and screenshots to the editable App Store version
- uploads release notes, screenshots, and the App Review PDF attachment to the editable App Store version
- generates `apps/ios/build/AppStoreRelease.xcconfig`
- archives `OpenClaw`
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile

View File

@@ -96,7 +96,7 @@ Pinned iOS version `2026.4.10` maps to:
- creates or verifies Developer Portal bundle IDs/services through Fastlane `produce`
- syncs encrypted App Store signing assets with Fastlane `match`
- increments App Store Connect build numbers for the pinned short version
- uploads screenshots and release notes before archiving a release build
- uploads screenshots, release notes, and the rendered App Review PDF attachment before archiving a release build
## Release-note resolution order
@@ -156,4 +156,4 @@ Fastlane and Xcode should consume only the pinned iOS version from `apps/ios/ver
Changing `package.json.version` alone must not change the iOS app version until a maintainer explicitly runs the pin step.
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, and upload builds, but it should not submit a build for review.
App Review submission must remain manual. Automation may create/update the editable App Store version, upload screenshots, upload release notes, upload the App Review PDF attachment, and upload builds, but it should not upload the App Store Connect `Notes` field or submit a build for review.

View File

@@ -47,6 +47,10 @@ PUBLIC_METADATA_FILENAMES = [
"subtitle.txt",
"support_url.txt"
].freeze
APP_REVIEW_NOTES_METADATA_FILENAMES = [
"notes.txt",
"review_notes.txt"
].freeze
def load_env_file(path)
return unless File.exist?(path)
@@ -732,6 +736,37 @@ def release_notes_metadata_path
temp_root
end
def app_review_notes_markdown_path
File.join(ios_root, "APP-REVIEW-NOTES.md")
end
def app_review_notes_pdf_path
File.join(ios_root, "build", "app-review", "APP-REVIEW-NOTES.pdf")
end
def generate_app_review_notes_pdf!
source = app_review_notes_markdown_path
UI.user_error!("Missing App Review notes at #{source}.") unless File.exist?(source)
output = app_review_notes_pdf_path
FileUtils.mkdir_p(File.dirname(output))
sh(shell_join(["xcrun", "swift", File.join(repo_root, "scripts", "ios-app-review-notes-pdf.swift"), source, output]))
output
end
def assert_no_app_review_notes_field_metadata!(metadata_path)
notes_dir = File.join(metadata_path, "review_information")
APP_REVIEW_NOTES_METADATA_FILENAMES.each do |filename|
path = File.join(notes_dir, filename)
next unless File.exist?(path)
UI.user_error!(
"Refusing to upload App Review Notes metadata from #{path}. " \
"Maintain the App Store Connect Notes field manually so the live setup code is not stored in this repo."
)
end
end
def public_metadata_path
source = File.join(__dir__, "metadata")
temp_root = Dir.mktmpdir("openclaw-app-store-metadata")
@@ -1014,7 +1049,7 @@ platform :ios do
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
desc "Generate screenshots, update App Store metadata and review attachment, then upload an App Store build"
lane :release_upload do
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
@@ -1044,7 +1079,7 @@ platform :ios do
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Upload App Store metadata (and optionally screenshots)"
desc "Upload App Store metadata, App Review PDF attachment, and optionally screenshots"
lane :metadata do
install_ready_for_review_edit_state_lookup!
sync_ios_versioning!
@@ -1064,12 +1099,15 @@ platform :ios do
validate_required_screenshots!(paths)
end
assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata"))
metadata_path = public_metadata_path
skip_metadata = ENV["DELIVER_METADATA"] != "1"
if release_notes_upload_requested? && skip_metadata
metadata_path = release_notes_metadata_path
skip_metadata = false
end
assert_no_app_review_notes_field_metadata!(metadata_path) unless skip_metadata
app_review_attachment_file = skip_metadata ? nil : generate_app_review_notes_pdf!
deliver_options = {
api_key: api_key,
@@ -1083,6 +1121,7 @@ platform :ios do
skip_metadata: skip_metadata,
skip_binary_upload: true,
overwrite_screenshots: screenshot_upload_requested?,
app_review_attachment_file: app_review_attachment_file,
skip_app_version_update: false,
submit_for_review: false,
run_precheck_before_submit: false

View File

@@ -169,5 +169,5 @@ Versioning rules:
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
- `pnpm ios:release:upload` generates and uploads screenshots, release notes, and the App Review PDF attachment before archiving, then uploads the IPA without submitting it for App Review or uploading the App Store Connect `Notes` field
- See `apps/ios/VERSIONING.md` for the detailed workflow

View File

@@ -2,7 +2,7 @@
This directory is used by `fastlane deliver` for App Store Connect text metadata.
## Upload metadata only
## Upload public metadata and App Review attachment
```bash
cd apps/ios
@@ -10,9 +10,9 @@ APP_STORE_CONNECT_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
DELIVER_METADATA=1 fastlane ios metadata
```
## Release notes only
## Release notes and App Review attachment
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes without rewriting all metadata:
`pnpm ios:release:upload` uses this mode before archiving so the editable App Store version has current release notes and the App Review PDF attachment without rewriting all metadata:
```bash
cd apps/ios
@@ -46,11 +46,12 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
- Locale files live under `metadata/en-US/`.
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
- `apps/ios/APP-REVIEW-NOTES.md` is rendered to `apps/ios/build/app-review/APP-REVIEW-NOTES.pdf` and uploaded as the App Review attachment when metadata is uploaded.
- Release notes resolve from `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
- When starting a new production release train, pin the iOS version first with `pnpm ios:version:pin -- --from-gateway`.
- The release upload flow uploads release notes and screenshots before the IPA, and never submits for App Review.
- The release upload flow uploads release notes, screenshots, and the App Review PDF attachment before the IPA, and never submits for App Review.
- `privacy_url.txt` is set to `https://openclaw.ai/privacy`.
- If app lookup fails in `deliver`, set one of:
- `APP_STORE_CONNECT_APP_IDENTIFIER` (bundle ID)
- `APP_STORE_CONNECT_APP_ID` (numeric App Store Connect app ID, e.g. from `/apps/<id>/...` URL)
- App Review submission is manual. Keep review contact, demo account, and reviewer notes outside this repo and enter them directly in App Store Connect when submitting for review.
- App Review submission is manual. Keep review contact, demo account, and the App Store Connect `Notes` field outside this repo and enter them directly in App Store Connect when submitting for review. Do not add `metadata/review_information/notes.txt`; the lane refuses to upload that field.

View File

@@ -1616,6 +1616,7 @@
"gateway:watch:raw": "node scripts/watch-node.mjs gateway --force",
"gen:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --write",
"ghsa:patch": "node scripts/ghsa-patch.mjs",
"ios:app-review-notes:pdf": "xcrun swift scripts/ios-app-review-notes-pdf.swift apps/ios/APP-REVIEW-NOTES.md apps/ios/build/app-review/APP-REVIEW-NOTES.pdf",
"ios:build": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'",
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env swift
import AppKit
import Foundation
func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data((message + "\n").utf8))
exit(1)
}
func htmlEscaped(_ value: String) -> String {
var escaped = ""
escaped.reserveCapacity(value.count)
for character in value {
switch character {
case "&":
escaped += "&amp;"
case "<":
escaped += "&lt;"
case ">":
escaped += "&gt;"
case "\"":
escaped += "&quot;"
default:
escaped.append(character)
}
}
return escaped
}
func renderInlineMarkdown(_ value: String) -> String {
let escaped = htmlEscaped(value)
let pieces = escaped.split(separator: "`", omittingEmptySubsequences: false)
guard pieces.count > 1 else {
return escaped
}
return pieces.enumerated().map { index, piece in
index.isMultiple(of: 2) ? String(piece) : "<code>\(piece)</code>"
}.joined()
}
func absoluteFileURL(_ path: String) -> URL {
if path.hasPrefix("/") {
return URL(fileURLWithPath: path).standardizedFileURL
}
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent(path)
.standardizedFileURL
}
func markdownToHTML(_ markdown: String) -> String {
var body: [String] = []
var paragraph: [String] = []
var inCodeBlock = false
var codeLines: [String] = []
var previousListItem = false
func flushParagraph() {
guard !paragraph.isEmpty else {
return
}
body.append("<p>\(paragraph.map(renderInlineMarkdown).joined(separator: " "))</p>")
paragraph.removeAll()
previousListItem = false
}
func flushCodeBlock() {
guard !codeLines.isEmpty else {
return
}
body.append("<pre><code>\(htmlEscaped(codeLines.joined(separator: "\n")))</code></pre>")
codeLines.removeAll()
previousListItem = false
}
let headingPattern = try! NSRegularExpression(pattern: "^(#{1,6})\\s+(.+)$")
let orderedListPattern = try! NSRegularExpression(pattern: "^\\s*(\\d+)\\.\\s+(.+)$")
let unorderedListPattern = try! NSRegularExpression(pattern: "^\\s*-\\s+(.+)$")
for rawLine in markdown.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("```") {
if inCodeBlock {
flushCodeBlock()
inCodeBlock = false
} else {
flushParagraph()
inCodeBlock = true
previousListItem = false
}
continue
}
if inCodeBlock {
codeLines.append(rawLine)
continue
}
if line.isEmpty {
flushParagraph()
previousListItem = false
continue
}
let lineRange = NSRange(line.startIndex..<line.endIndex, in: line)
if let match = headingPattern.firstMatch(in: line, range: lineRange),
let levelRange = Range(match.range(at: 1), in: line),
let textRange = Range(match.range(at: 2), in: line) {
flushParagraph()
let level = line[levelRange].count
if level == 2 {
body.append("<p class=\"section-spacer\">&nbsp;</p>")
body.append("<p class=\"section-spacer\">&nbsp;</p>")
}
body.append("<h\(level)>\(renderInlineMarkdown(String(line[textRange])))</h\(level)>")
if level <= 2 {
body.append("<p class=\"heading-rule\">\(String(repeating: "", count: 85))</p>")
}
previousListItem = false
continue
}
if let match = orderedListPattern.firstMatch(in: line, range: lineRange),
let numberRange = Range(match.range(at: 1), in: line),
let itemRange = Range(match.range(at: 2), in: line) {
flushParagraph()
let marker = htmlEscaped(String(line[numberRange])) + "."
body.append(
"<p class=\"list-item\"><span class=\"marker\">\(marker)</span> \(renderInlineMarkdown(String(line[itemRange])))</p>"
)
previousListItem = true
continue
}
if let match = unorderedListPattern.firstMatch(in: line, range: lineRange),
let itemRange = Range(match.range(at: 1), in: line) {
flushParagraph()
body.append("<p class=\"list-item\"><span class=\"marker\">&bull;</span> \(renderInlineMarkdown(String(line[itemRange])))</p>")
previousListItem = true
continue
}
if previousListItem && rawLine.hasPrefix(" ") {
body.append("<p class=\"list-continuation\">\(renderInlineMarkdown(line))</p>")
} else {
paragraph.append(line)
previousListItem = false
}
}
if inCodeBlock {
flushCodeBlock()
}
flushParagraph()
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
background: #ffffff;
color: #111111;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 10pt;
line-height: 1.42;
}
h1 {
color: #111111;
font-size: 25pt;
margin: 0 0 5pt 0;
}
h2 {
color: #111111;
font-size: 17pt;
margin: 0 0 5pt 0;
}
.section-spacer {
color: #ffffff;
font-size: 10pt;
margin: 0;
}
.heading-rule {
color: #d8dee4;
font-family: Menlo, Monaco, monospace;
font-size: 6pt;
line-height: 0.55;
margin: 0 0 7pt 0;
}
p {
margin: 0 0 9pt 0;
}
.list-item {
margin: 0 0 5pt 0;
padding-left: 24pt;
text-indent: -24pt;
}
.marker {
display: inline-block;
min-width: 20pt;
}
.list-continuation {
margin-left: 24pt;
}
code {
background: #eef2f6;
border-radius: 4pt;
color: #24292f;
font-family: Menlo, Monaco, monospace;
font-size: 9.5pt;
padding: 1.5pt 3pt;
}
pre {
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 5pt;
color: #24292f;
margin: 0 0 10pt 0;
padding: 8pt;
}
pre code {
background: transparent;
border-radius: 0;
padding: 0;
}
</style>
</head>
<body>
\(body.joined(separator: "\n"))
</body>
</html>
"""
}
let arguments = CommandLine.arguments
guard arguments.count == 3 else {
fail("Usage: scripts/ios-app-review-notes-pdf.swift <APP-REVIEW-NOTES.md> <output.pdf>")
}
let sourceURL = absoluteFileURL(arguments[1])
let outputURL = absoluteFileURL(arguments[2])
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
fail("Missing App Review notes Markdown: \(sourceURL.path)")
}
let markdown: String
do {
markdown = try String(contentsOf: sourceURL, encoding: .utf8)
} catch {
fail("Failed to read \(sourceURL.path): \(error)")
}
let htmlData = Data(markdownToHTML(markdown).utf8)
let attributed: NSAttributedString
do {
attributed = try NSAttributedString(
data: htmlData,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)
} catch {
fail("Failed to render App Review notes Markdown as HTML: \(error)")
}
let fileManager = FileManager.default
do {
try fileManager.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
} catch {
fail("Failed to create PDF output directory: \(error)")
}
let pageSize = NSSize(width: 612, height: 792)
let margin: CGFloat = 42
let textWidth = pageSize.width - (margin * 2)
let textStorage = NSTextStorage(attributedString: attributed)
let layoutManager = NSLayoutManager()
layoutManager.usesFontLeading = true
textStorage.addLayoutManager(layoutManager)
let contentSize = NSSize(width: textWidth, height: pageSize.height - (margin * 2))
var pageGlyphRanges: [NSRange] = []
while pageGlyphRanges.last.map({ NSMaxRange($0) < layoutManager.numberOfGlyphs }) ?? true {
let textContainer = NSTextContainer(containerSize: contentSize)
textContainer.lineFragmentPadding = 0
layoutManager.addTextContainer(textContainer)
layoutManager.ensureLayout(for: textContainer)
let glyphRange = layoutManager.glyphRange(for: textContainer)
guard glyphRange.length > 0 else {
break
}
pageGlyphRanges.append(glyphRange)
if pageGlyphRanges.count > 100 {
fail("Refusing to render more than 100 App Review notes PDF pages.")
}
}
let pdfData = NSMutableData()
var mediaBox = CGRect(origin: .zero, size: pageSize)
guard let dataConsumer = CGDataConsumer(data: pdfData),
let pdfContext = CGContext(consumer: dataConsumer, mediaBox: &mediaBox, nil) else {
fail("Failed to create PDF context: \(outputURL.path)")
}
let pageBackground = CGColor(gray: 1, alpha: 1)
for glyphRange in pageGlyphRanges {
pdfContext.beginPDFPage(nil)
pdfContext.setFillColor(pageBackground)
pdfContext.fill(mediaBox)
pdfContext.saveGState()
pdfContext.translateBy(x: 0, y: pageSize.height)
pdfContext.scaleBy(x: 1, y: -1)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(cgContext: pdfContext, flipped: true)
layoutManager.drawBackground(forGlyphRange: glyphRange, at: NSPoint(x: margin, y: margin))
layoutManager.drawGlyphs(forGlyphRange: glyphRange, at: NSPoint(x: margin, y: margin))
NSGraphicsContext.restoreGraphicsState()
pdfContext.restoreGState()
pdfContext.endPDFPage()
}
pdfContext.closePDF()
do {
try pdfData.write(to: outputURL, options: .atomic)
} catch {
fail("Failed to write App Review notes PDF: \(error)")
}
print(outputURL.path)