We are currently building our iOS app DocC static-hosted documentation using Github CI and Github Pages.
The build and deployment take about 55 minutes and involve multiple steps. We're combining multiple modules' documentation into a single site/archive, but it's unclear if we're going about this in the right way.
Are there any tips for speeding this up? Are all the following steps necessary, or any flags we can add to optimize?
name: Deploy DocC
'on':
schedule:
- cron: '0 0,12 * * *'
workflow_dispatch: null
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: macos-15-xlarge
env:
MODULES_DIR: "./Modules"
DERIVED_DATA: "./tmp/docbuild"
SYMBOL_GRAPHS_DIR: "./tmp/symbol-graphs"
ARCHIVES_DIR: "./tmp/doccarchives"
MERGED_DOCC_ARCHIVE: "./MergedDocs.doccarchive"
STATIC_OUTPUT_DIR: "./docs"
PACKAGE_JSON: "./tmp/package.json"
SWIFT_DOCC: "./swift-docc/.build/debug/docc"
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
fetch-depth: 0
lfs: true
- name: Prepare signing certificates
env:
REDACTED
- name: Install Swift-DocC
run: |
git clone https://github.com/apple/swift-docc.git
cd swift-docc
swift build
cd ..
- name: Install current Bash on macOS
run: |
brew install bash
echo "Using Bash version: $(bash --version | head -n1)"
- name: Set up directories for documentation
run: |
rm -rf "${ARCHIVES_DIR}" "${MERGED_DOCC_ARCHIVE}" "${STATIC_OUTPUT_DIR}" "${SYMBOL_GRAPHS_DIR}"
mkdir -p "${ARCHIVES_DIR}" "${STATIC_OUTPUT_DIR}" "${SYMBOL_GRAPHS_DIR}"
- name: Generate package.json
run: |
echo "๐ฆ Generating package.json from Package.swift..."
if [ ! -f "${MODULES_DIR}/Package.swift" ]; then
echo "โ Error: Package.swift not found in ${MODULES_DIR}"
exit 1
fi
(cd "${MODULES_DIR}" && swift package describe --type json) > "${PACKAGE_JSON}"
- name: Validate package.json
run: |
if [ ! -f "${PACKAGE_JSON}" ]; then
echo "โ Error: Failed to generate package.json"
exit 1
fi
echo "โ package.json generated successfully"
- name: Build with symbol graphs
run: |
xcodebuild -parallelizeTargets docbuild \
-project ProjectName.xcodeproj \
-scheme ProjectScheme \
-derivedDataPath "${DERIVED_DATA}" \
-destination 'generic/platform=iOS' \
ENABLE_SYMBOL_GRAPHS=YES \
CODE_SIGN_STYLE=Manual
- name: Copy symbol graphs
run: |
set -e
shopt -s nullglob globstar
TARGETS=$(jq -r '
.targets[]? |
select(.type == "library" or .type == "executable") |
.name' "${PACKAGE_JSON}")
if [[ -z "$TARGETS" ]]; then
echo "โ No targets found in package.json."
exit 1
fi
echo "๐ฏ Targets found: $TARGETS"
# Create an associative array for dependencies (Bash 5+)
declare -A TARGET_DEPENDENCIES
declare -A TARGET_ARCHIVES
# Extract dependencies for each target
while read -r TARGET; do
echo "๐ Processing target: $TARGET"
DEPENDENCIES=$(jq -r --arg TARGET "$TARGET" '
.targets[]? |
select(.name == $TARGET) |
.target_dependencies?[]?' "$PACKAGE_JSON")
# Handle null case for dependencies
if [[ -n "$DEPENDENCIES" ]]; then
TARGET_DEPENDENCIES["$TARGET"]="$DEPENDENCIES"
else
echo "โ ๏ธ No dependencies found for target: $TARGET"
fi
done <<< "$TARGETS"
# Print extracted dependencies for debugging
echo "โ Targets and their dependencies:"
for TARGET in "${!TARGET_DEPENDENCIES[@]}"; do
echo "๐ $TARGET depends on: ${TARGET_DEPENDENCIES[$TARGET]}"
done
for TARGET in $TARGETS; do
TARGET_SYMBOL_GRAPHS_DIR="${SYMBOL_GRAPHS_DIR}/${TARGET}"
mkdir -p "$TARGET_SYMBOL_GRAPHS_DIR"
XCODE_SYMBOL_GRAPH_DIR_IOS="${DERIVED_DATA}/Build/Intermediates.noindex/ProjectNameModules.build/Debug-iphoneos/${TARGET}.build/symbol-graph"
if [ -d "$XCODE_SYMBOL_GRAPH_DIR_IOS" ]; then
cp -R "${XCODE_SYMBOL_GRAPH_DIR_IOS}"
fi
done
echo "๐๏ธ Start Converting documentation for each target..."
for TARGET in $TARGETS; do
DOCC_CATALOG=""
while IFS= read -r -d '' docc_dir; do
DOCC_CATALOG="$docc_dir"
echo "๐ Found documentation catalog: $DOCC_CATALOG"
break # Take the first one found
done < <(find "${MODULES_DIR}/Sources/${TARGET}" -type d -name "*.docc" -print0)
TARGET_SYMBOL_GRAPHS_DIR="${SYMBOL_GRAPHS_DIR}/${TARGET}"
# Skip if no docc catalog AND no symbol graphs
if [[ -z "$DOCC_CATALOG" ]] && [[ -z "$(ls -A "$TARGET_SYMBOL_GRAPHS_DIR")" ]]; then
echo "โ ๏ธ Skipping $TARGET - No documentation catalog or symbol graphs."
continue
fi
echo "๐จ Building documentation for target: $TARGET"
echo "Symbol graphs directory: $TARGET_SYMBOL_GRAPHS_DIR"
echo "Contents of symbol graphs directory:"
ls -la "$TARGET_SYMBOL_GRAPHS_DIR"
DEPENDENCY_FLAGS=()
for DEP in ${TARGET_DEPENDENCIES["$TARGET"]}; do
if [[ -n "${TARGET_ARCHIVES[$DEP]}" ]]; then
echo "๐ Adding dependency on ${TARGET_ARCHIVES[$DEP]}"
DEPENDENCY_FLAGS+=(--dependency "${TARGET_ARCHIVES[$DEP]}")
fi
done
ARCHIVE_PATH="${ARCHIVES_DIR}/${TARGET}.doccarchive"
# Add symbol graphs to convert command if they exist
SYMBOL_GRAPH_FLAGS=()
if [[ -n "$(ls -A "$TARGET_SYMBOL_GRAPHS_DIR")" ]]; then
SYMBOL_GRAPH_FLAGS+=(--additional-symbol-graph-dir "$TARGET_SYMBOL_GRAPHS_DIR")
fi
# Build docc convert command based on what's available
CONVERT_ARGS=()
if [[ -n "$DOCC_CATALOG" ]]; then
CONVERT_ARGS+=("$DOCC_CATALOG")
echo "Using .docc catalog: $DOCC_CATALOG"
echo "Contents of .docc catalog:"
ls -la "$DOCC_CATALOG"
else
echo "No .docc catalog found, using symbol graphs only"
fi
CONVERT_ARGS+=(
--fallback-display-name "$TARGET"
--fallback-bundle-identifier "com.example.${TARGET}"
--fallback-bundle-version "1.0.0"
--output-path "$ARCHIVE_PATH"
"${DEPENDENCY_FLAGS[@]}"
"${SYMBOL_GRAPH_FLAGS[@]}"
--emit-digest
--enable-experimental-external-link-support
--enable-experimental-overloaded-symbol-presentation
--enable-experimental-mentioned-in
--hosting-base-path "$TARGET"
--no-transform-for-static-hosting
)
echo "Running command: $SWIFT_DOCC convert ${CONVERT_ARGS[*]}"
if ! "$SWIFT_DOCC" convert "${CONVERT_ARGS[@]}" 2>docc_error_${TARGET}.log; then
echo "โ Failed to convert documentation for $TARGET"
echo "Error output:"
cat docc_error_${TARGET}.log
echo "Symbol graphs contents:"
ls -la "$TARGET_SYMBOL_GRAPHS_DIR"
if [[ -n "$DOCC_CATALOG" ]]; then
echo ".docc catalog contents:"
ls -la "$DOCC_CATALOG"
fi
exit 1
fi
echo "โ Successfully converted documentation for $TARGET"
TARGET_ARCHIVES["$TARGET"]="$ARCHIVE_PATH"
done
- name: Merge documentation archives
run: |
readarray -d '' DOCC_ARCHIVES < <(find "${ARCHIVES_DIR}" -type d -name "*.doccarchive" -print0)
"${SWIFT_DOCC}" merge "${DOCC_ARCHIVES[@]}" \
--output-path "${MERGED_DOCC_ARCHIVE}" \
--synthesized-landing-page-name "App Name iOS"
- name: Transform for static hosting
run: |
xcrun docc process-archive transform-for-static-hosting "${MERGED_DOCC_ARCHIVE}" \
--hosting-base-path "" \
--output-path "${STATIC_OUTPUT_DIR}"
- name: Redirect base url to documentation
run: |
echo '<script>window.location.href += "documentation/"</script>' > "${STATIC_OUTPUT_DIR}/index.html"
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs
- id: deployment
name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4
I haven't tried the specifics here, but the only thing that calls out to me is if you need to keep the building of the catalogs separate, merging them later. I think there may be able to collapse the build and merge into a single step. Although since you need iOS specific builds, that might not be an option. I think the only merged step setup there is with swift-docc-plugin (--enable-experimental-combined-documentation).
I'd love to see some concrete numbers on what's taking the most time with these steps - my supposition is that the majority of time is coming from the xcodebuild docbuild commands. Are you willing to share timing for your use case (suitably anonymized, if you prefer)?
At least at the time of originally doing this, the toolchain version didn't support some of the merge flags we're using. Either way, building of Docc is pretty fast so it's the lowest of the bottlenecks. I'll follow up shortly with the brought timing we're seeing.
I've only glanced at your build script but there seems to be a large amount of repeated or unnecessary work.
Your "Build with symbol graphs" task isn't just generating symbol graphs. Because you're using the docbuild action that task is building the documentation archives for everything in that "ProjectScheme" (including dependencies and transitive dependencies).
Later in the "Copy symbol graphs" task you are building all the documentation again in order to pass the dependency archives but AFAICT you're still building the targets in the original order, not in dependency order. If that's correct (that you're not building the targets in dependency order), it looks like you're also only passing the dependencies if they've been built already, so you might be missing dependencies even though you have a copy of all the archives from the previous "Build with symbol graphs" task.
Some smaller things;
I don't see anything that uses any of the --emit-digest output so that's likely just (a nontrivial amount of) redundant work.
When you build the documentation archives the second time you pass --no-transform-for-static-hosting but later you have an entire docc process-archive transform-for-static-hosting step. This leads to unnecessary IO because you need to read all the files again when you previously had in-memory representations of them.
Here's an updated script that removes the duplication, but takes the same amount of time unless I'm missing something. It also didn't create the static website despite the "transform-for-static-hosting" flag.
These steps also seem pretty involved in general; not sure how anyone else would know the right steps for getting documentation from many modules into a single static site.
name: Deploy DocC
'on':
schedule:
- cron: '0 0,12 * * *'
workflow_dispatch: null
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: macos-15-xlarge
env:
MODULES_DIR: "./Modules"
DERIVED_DATA: "./tmp/docbuild"
SYMBOL_GRAPHS_DIR: "./tmp/symbol-graphs"
ARCHIVES_DIR: "./tmp/doccarchives"
MERGED_DOCC_ARCHIVE: "./MergedDocs.doccarchive"
STATIC_OUTPUT_DIR: "./docs"
PACKAGE_JSON: "./tmp/package.json"
steps:
- name: Add Private Repo Auth
run: git config --global --add url."https://${GITHUB_ACCESS_TOKEN}@github.com/${GITHUB_REPOSITORY_OWNER}".insteadOf "https://github.com/${GITHUB_REPOSITORY_OWNER}"
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
fetch-depth: 0
lfs: true
- name: Setup uv
uses: astral-sh/setup-uv@v5
- name: Select Xcode Version
run: scripts/xcode-version.py --select
- name: Checkout LFS objects
run: git lfs checkout
- name: Prepare signing certificates
env:
REDACTED
- name: Install current Bash on macOS
run: |
brew install bash
echo "Using Bash version: $(bash --version | head -n1)"
- name: Set up directories for documentation
run: |
rm -rf "${ARCHIVES_DIR}" "${MERGED_DOCC_ARCHIVE}" "${STATIC_OUTPUT_DIR}" "${SYMBOL_GRAPHS_DIR}"
mkdir -p "${ARCHIVES_DIR}" "${STATIC_OUTPUT_DIR}" "${SYMBOL_GRAPHS_DIR}"
- name: Generate package.json
run: |
echo "๐ฆ Generating package.json from Package.swift..."
(cd "${MODULES_DIR}" && swift package describe --type json) > "${PACKAGE_JSON}"
- name: Validate package.json
run: |
if [ ! -f "${PACKAGE_JSON}" ]; then
echo "โ Error: Failed to generate package.json"
exit 1
fi
- name: Build with symbol graphs
run: |
xcodebuild -parallelizeTargets docbuild \
-project AppName.xcodeproj \
-scheme AppName \
-derivedDataPath "${DERIVED_DATA}" \
-destination 'generic/platform=iOS' \
ENABLE_SYMBOL_GRAPHS=YES
- name: Extract and build documentation archives in dependency order
run: |
set -e
shopt -s nullglob globstar
# Get targets and sort them in dependency order
TARGETS=$(jq -r '
.targets[]? |
select(.type == "library" or .type == "executable") |
.name' "${PACKAGE_JSON}")
if [[ -z "$TARGETS" ]]; then
echo "โ No targets found in package.json."
exit 1
fi
echo "๐ฏ Targets found: $TARGETS"
# Create dependency graph and sort targets topologically
declare -A TARGET_DEPENDENCIES
declare -A TARGET_ARCHIVES
declare -a PROCESSED_TARGETS
declare -a REMAINING_TARGETS
# Extract dependencies for each target
while read -r TARGET; do
echo "๐ Processing target: $TARGET"
DEPENDENCIES=$(jq -r --arg TARGET "$TARGET" '
.targets[]? |
select(.name == $TARGET) |
.target_dependencies?[]?' "$PACKAGE_JSON")
# Handle null case for dependencies
if [[ -n "$DEPENDENCIES" ]]; then
TARGET_DEPENDENCIES["$TARGET"]="$DEPENDENCIES"
else
echo "โ ๏ธ No dependencies found for target: $TARGET"
TARGET_DEPENDENCIES["$TARGET"]=""
fi
REMAINING_TARGETS+=("$TARGET")
done <<< "$TARGETS"
# Simple topological sort - process targets with no unprocessed dependencies first
while [[ ${#REMAINING_TARGETS[@]} -gt 0 ]]; do
PROGRESS_MADE=false
NEW_REMAINING=()
for TARGET in "${REMAINING_TARGETS[@]}"; do
CAN_PROCESS=true
for DEP in ${TARGET_DEPENDENCIES["$TARGET"]}; do
if [[ ! " ${PROCESSED_TARGETS[*]} " =~ " ${DEP} " ]] && [[ " ${REMAINING_TARGETS[*]} " =~ " ${DEP} " ]]; then
CAN_PROCESS=false
break
fi
done
if [[ "$CAN_PROCESS" == true ]]; then
echo "๐จ Processing target: $TARGET (dependencies satisfied)"
# Copy symbol graphs for this target
TARGET_SYMBOL_GRAPHS_DIR="${SYMBOL_GRAPHS_DIR}/${TARGET}"
mkdir -p "$TARGET_SYMBOL_GRAPHS_DIR"
XCODE_SYMBOL_GRAPH_DIR_IOS="${DERIVED_DATA}/Build/Intermediates.noindex/AppNameModules.build/Debug-iphoneos/${TARGET}.build/symbol-graph"
if [ -d "$XCODE_SYMBOL_GRAPH_DIR_IOS" ]; then
cp -R "${XCODE_SYMBOL_GRAPH_DIR_IOS}"
fi
# Find documentation catalog
DOCC_CATALOG=""
while IFS= read -r -d '' docc_dir; do
DOCC_CATALOG="$docc_dir"
echo "๐ Found documentation catalog: $DOCC_CATALOG"
break
done < <(find "${MODULES_DIR}/Sources/${TARGET}" -type d -name "*.docc" -print0)
# Skip if no docc catalog AND no symbol graphs
if [[ -z "$DOCC_CATALOG" ]] && [[ -z "$(ls -A "$TARGET_SYMBOL_GRAPHS_DIR")" ]]; then
echo "โ ๏ธ Skipping $TARGET - No documentation catalog or symbol graphs."
PROCESSED_TARGETS+=("$TARGET")
PROGRESS_MADE=true
continue
fi
# Build dependency flags from already processed targets
DEPENDENCY_FLAGS=()
for DEP in ${TARGET_DEPENDENCIES["$TARGET"]}; do
if [[ -n "${TARGET_ARCHIVES[$DEP]}" ]]; then
echo "๐ Adding dependency on ${TARGET_ARCHIVES[$DEP]}"
DEPENDENCY_FLAGS+=(--dependency "${TARGET_ARCHIVES[$DEP]}")
fi
done
ARCHIVE_PATH="${ARCHIVES_DIR}/${TARGET}.doccarchive"
# Add symbol graphs to convert command if they exist
SYMBOL_GRAPH_FLAGS=()
if [[ -n "$(ls -A "$TARGET_SYMBOL_GRAPHS_DIR")" ]]; then
SYMBOL_GRAPH_FLAGS+=(--additional-symbol-graph-dir "$TARGET_SYMBOL_GRAPHS_DIR")
fi
# Build docc convert command
CONVERT_ARGS=()
if [[ -n "$DOCC_CATALOG" ]]; then
CONVERT_ARGS+=("$DOCC_CATALOG")
fi
CONVERT_ARGS+=(
--fallback-display-name "$TARGET"
--fallback-bundle-identifier "com.example.${TARGET}"
--fallback-bundle-version "1.0.0"
--output-path "$ARCHIVE_PATH"
"${DEPENDENCY_FLAGS[@]}"
"${SYMBOL_GRAPH_FLAGS[@]}"
--enable-experimental-external-link-support
--enable-experimental-overloaded-symbol-presentation
--hosting-base-path "$TARGET"
--transform-for-static-hosting
)
echo "Running command: xcrun docc convert ${CONVERT_ARGS[*]}"
if ! xcrun docc convert "${CONVERT_ARGS[@]}" 2>docc_error_${TARGET}.log; then
echo "โ Failed to convert documentation for $TARGET"
echo "Error output:"
cat docc_error_${TARGET}.log
echo "โ ๏ธ Skipping $TARGET due to conversion failure."
else
echo "โ Successfully converted documentation for $TARGET"
TARGET_ARCHIVES["$TARGET"]="$ARCHIVE_PATH"
fi
PROCESSED_TARGETS+=("$TARGET")
PROGRESS_MADE=true
else
NEW_REMAINING+=("$TARGET")
fi
done
REMAINING_TARGETS=("${NEW_REMAINING[@]}")
if [[ "$PROGRESS_MADE" == false ]] && [[ ${#REMAINING_TARGETS[@]} -gt 0 ]]; then
echo "โ Circular dependency detected or missing dependencies. Remaining targets:"
printf '%s\n' "${REMAINING_TARGETS[@]}"
break
fi
done
echo "โ Processed targets in dependency order: ${PROCESSED_TARGETS[*]}"
- name: Cleanup Derived Data and Symbol Graphs
if: always()
run: |
echo "๐งน Cleaning up DerivedData and Symbol Graphs..."
rm -rf "${DERIVED_DATA}" "${SYMBOL_GRAPHS_DIR}"
- name: Merge documentation archives
run: |
readarray -d '' DOCC_ARCHIVES < <(find "${ARCHIVES_DIR}" -type d -name "*.doccarchive" -print0)
if [[ ${#DOCC_ARCHIVES[@]} -eq 0 ]]; then
echo "โ No documentation archives found to merge"
exit 1
fi
echo "๐ Merging ${#DOCC_ARCHIVES[@]} documentation archive(s)..."
xcrun docc merge "${DOCC_ARCHIVES[@]}" \
--output-path "${MERGED_DOCC_ARCHIVE}" \
--synthesized-landing-page-name "AppName iOS"
- name: Copy merged archive for static hosting
run: |
# Since individual archives were already transformed for static hosting,
# we just need to copy the merged archive to the output directory
cp -R "${MERGED_DOCC_ARCHIVE}" "${STATIC_OUTPUT_DIR}/documentation"
- name: Redirect base url to documentation
run: |
echo '<script>window.location.href += "documentation/"</script>' > "${STATIC_OUTPUT_DIR}/index.html"
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs
- id: deployment
name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4
- name: Final Cleanup
if: always()
run: |
echo "๐งน Final cleanup of archives and temp files..."
rm -rf "${ARCHIVES_DIR}" "${MERGED_DOCC_ARCHIVE}" "${PACKAGE_JSON}"
The updated script hasn't really removed any duplicated work, so it's not surprising that it still takes the same amount of time.
The "Build with symbol graphs" task still performs the docbuild action instead of the (default) build action which means that it's still s building the documentation archives for everything in the scheme (including dependencies and transitive dependencies):
- name: Build with symbol graphs
run: |
xcodebuild -parallelizeTargets docbuild \
...
Individual developers aren't meant to perform these steps manually like this. The idea is that the tools would do it for them. For Swift packages building their documentation using the Swift-DocC Plugin, this is already the case. If you pass the --enable-experimental-combined-documentation to swift package generate-documentation, the plugin:
schedules symbol graph generation and documentation builds for targets in dependency order, running independent tasks concurrently where possible
passes dependency archives between target's documentation builds
merges the built documentation archives into a single combined output and writes it to the specified --output-path (or the default output location).
However, if you can't build your Swift package with swift build then you also can't use swift package generate-documentation to build documentation for it. Instead, you end up needing to perform these steps manually to achieve the same output because the tools you need to use to build your project doesn't support doing that work you yet.
Right. The main issue is that we're not trying to build a Swift Package documentation, as this is for our iOS app. We're aiming to create internal documentation. So, we don't have a clear step-by-step on the right script/order of build steps for non-swift packages. We've had to piece together suggestions from various places leading to our most recent one that works, but isn't clear if it's the best solution.