From b93eeceac0f5c78f3275d04d15e10e91bedbe8c7 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:18:07 -0500 Subject: [PATCH] build(ios): attach app review notes PDF --- apps/ios/README.md | 6 +- apps/ios/VERSIONING.md | 4 +- apps/ios/fastlane/Fastfile | 43 ++- apps/ios/fastlane/SETUP.md | 2 +- apps/ios/fastlane/metadata/README.md | 11 +- package.json | 1 + scripts/ios-app-review-notes-pdf.swift | 347 +++++++++++++++++++++++++ 7 files changed, 401 insertions(+), 13 deletions(-) create mode 100644 scripts/ios-app-review-notes-pdf.swift diff --git a/apps/ios/README.md b/apps/ios/README.md index d5c2bb9d6f3c..e746936cfdbd 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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 diff --git a/apps/ios/VERSIONING.md b/apps/ios/VERSIONING.md index 4b2b32439f76..94d72416f42a 100644 --- a/apps/ios/VERSIONING.md +++ b/apps/ios/VERSIONING.md @@ -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. diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 5c8298235c93..f57e21b6dbb8 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -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 diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index c94b815c92ba..2e11913ce599 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -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 diff --git a/apps/ios/fastlane/metadata/README.md b/apps/ios/fastlane/metadata/README.md index c53e3eb73391..79e5151ce291 100644 --- a/apps/ios/fastlane/metadata/README.md +++ b/apps/ios/fastlane/metadata/README.md @@ -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 `## ` 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//...` 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. diff --git a/package.json b/package.json index b9e58b57a3fd..35b9d9380c6a 100644 --- a/package.json +++ b/package.json @@ -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'", diff --git a/scripts/ios-app-review-notes-pdf.swift b/scripts/ios-app-review-notes-pdf.swift new file mode 100644 index 000000000000..3d809cee8925 --- /dev/null +++ b/scripts/ios-app-review-notes-pdf.swift @@ -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 += "&" + case "<": + escaped += "<" + case ">": + escaped += ">" + case "\"": + escaped += """ + 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) : "\(piece)" + }.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("

\(paragraph.map(renderInlineMarkdown).joined(separator: " "))

") + paragraph.removeAll() + previousListItem = false + } + + func flushCodeBlock() { + guard !codeLines.isEmpty else { + return + } + body.append("
\(htmlEscaped(codeLines.joined(separator: "\n")))
") + 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.. 

") + body.append("

 

") + } + body.append("\(renderInlineMarkdown(String(line[textRange])))") + if level <= 2 { + body.append("

\(String(repeating: "─", count: 85))

") + } + 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( + "

\(marker) \(renderInlineMarkdown(String(line[itemRange])))

" + ) + previousListItem = true + continue + } + + if let match = unorderedListPattern.firstMatch(in: line, range: lineRange), + let itemRange = Range(match.range(at: 1), in: line) { + flushParagraph() + body.append("

\(renderInlineMarkdown(String(line[itemRange])))

") + previousListItem = true + continue + } + + if previousListItem && rawLine.hasPrefix(" ") { + body.append("

\(renderInlineMarkdown(line))

") + } else { + paragraph.append(line) + previousListItem = false + } + } + + if inCodeBlock { + flushCodeBlock() + } + flushParagraph() + + return """ + + + + + + + + \(body.joined(separator: "\n")) + + + """ +} + +let arguments = CommandLine.arguments +guard arguments.count == 3 else { + fail("Usage: scripts/ios-app-review-notes-pdf.swift ") +} + +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)