Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/build-identity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Build identity
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 scripts/test-build-identity.py
- run: ruby scripts/test-fastlane-identity.rb
- run: python3 scripts/test-archive-command.py
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
/privacycommand/privacycommand.xcodeproj/project.xcworkspace/xcuserdata
*.xcuserdatad
/privacycommand/privacycommand.xcodeproj/project.xcworkspace

# Local Archive outputs generated by the shared archive command.
/build/
11 changes: 11 additions & 0 deletions Config/ArchiveTargets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"project": "privacycommand/privacycommand.xcodeproj",
"spec": null,
"defaultPlatform": "macos",
"platforms": {
"macos": {
"scheme": "privacycommand",
"destination": "generic/platform=macOS"
}
}
}
13 changes: 13 additions & 0 deletions Config/Shared.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Shared marketing version and generated build identity for every shipping target.
MARKETING_VERSION = 0.1.5
CURRENT_PROJECT_VERSION = $(BUILD_NUMBER)
BUILD_NUMBER = 0
BUILD_SHA =
BUILD_BRANCH =
BUILD_DIRTY = NO
BUILD_DIRTY_FILES = 0
BUILD_DIFF_ID =
BUILD_DESCRIBE =
BUILD_TAGGED = NO
BUILD_TIMESTAMP =
BUILD_CHANNEL = xcode
26 changes: 26 additions & 0 deletions docs/ARCHIVING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Archive and build identity

The portfolio uses `YYYY.MMDD.HHMM` in UTC for build numbers. The marketing
version lives in `Config/Shared.xcconfig`; repository provenance is separate.
See the [canonical agreement](https://github.com/AdamXweb/AdamXweb/blob/main/docs/VERSIONING.md).

Open the shared app scheme, select a device or Any Mac, then choose
**Product → Archive**. Each build captures a fresh identity automatically;
the app and its extensions share it. Generated projects must be regenerated
after pulling project changes.

`just archive` generates the project when needed, archives, and checks the
Organizer, app and extension versions. Supported platforms are listed in
`Config/ArchiveTargets.json`; pass one with `just archive PLATFORM`.
For compile and metadata verification without certificates, run
`python3 scripts/archive-app.py --unsigned`. Add `--plan` to inspect commands.

Normal archives use the project's signing setup and your Apple account.
Set `APPLE_TEAM_ID` when a team override is needed. Export and upload remain
separate. Release archives omit repository identifiers. Marketing versions
are unchanged by archiving.

The format has minute precision: wait for the next UTC minute before
uploading a second build of the same version. An existing App Store version
with a larger legacy integer build may need a marketing version bump before
the first timestamp build can be uploaded.
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ release-local:
[group("dev")]
clean:
rm -rf privacycommand/.build

# Regenerate the project, Archive, and verify its app/extension build identity.
archive platform="macos":
python3 scripts/archive-app.py --platform {{quote(platform)}}
211 changes: 135 additions & 76 deletions privacycommand/privacycommand.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction ActionType="Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent title="Capture shared build identity" scriptText="env -u SDKROOT /usr/bin/python3 &quot;$SRCROOT/../scripts/prepare-build-info.py&quot; session">
<EnvironmentBuildable>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="1100000000000000000000A0" BuildableName="privacycommand.app" BlueprintName="privacycommand" ReferencedContainer="container:privacycommand.xcodeproj"/>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
Expand Down
48 changes: 48 additions & 0 deletions scripts/archive-app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Regenerate if needed, archive with Xcode's normal version capture, then verify it.

This command creates an archive only. Export and upload remain separate actions.
"""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess

root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--platform")
parser.add_argument("--unsigned", action="store_true", help="Validate an archive without a signing identity")
parser.add_argument("--plan", action="store_true", help="Print the commands without running them")
args = parser.parse_args()
config = json.loads((root / "Config/ArchiveTargets.json").read_text())
platform = args.platform or config["defaultPlatform"]
if platform not in config["platforms"]:
parser.error(f"Choose a supported platform: {', '.join(config['platforms'])}")
target = config["platforms"][platform]
commands = []
if config.get("spec"):
commands.append(["xcodegen", "generate", "--spec", config["spec"]])
archive = root / "build" / (target["scheme"] + "-" + platform + ".xcarchive")
command = ["xcodebuild", "archive", "-project", config["project"], "-scheme", target["scheme"],
"-configuration", "Release", "-destination", target["destination"], "-archivePath", str(archive)]
if args.unsigned:
command.append("CODE_SIGNING_ALLOWED=NO")
else:
command.append("-allowProvisioningUpdates")
team = next((os.environ[name] for name in ("APPLE_TEAM_ID", "TEAM_ID", "FASTLANE_TEAM_ID")
if os.environ.get(name)), "")
if team:
if not re.fullmatch(r"[A-Z0-9]{10}", team):
parser.error("Apple team ID must have 10 uppercase letters/digits")
command.append("DEVELOPMENT_TEAM=" + team)
commands.append(command)
commands.append(["python3", str(Path(__file__).with_name("verify-archive.py")), str(archive)])
if args.plan:
print(json.dumps(commands, indent=2))
else:
env = dict(os.environ, BUILD_CHANNEL="release")
for command in commands:
subprocess.run(command, cwd=root, env=env, check=True)
print(f"Archive ready: {archive}")
49 changes: 49 additions & 0 deletions scripts/banner.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Human-readable rendering of Scripts/buildinfo.sh, printed before a build so
# you know what you are about to test. Usage: Scripts/banner.sh [AppName]
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd)"
cd "$script_dir/.."

# Parse data instead of evaluating repository-controlled branch/tag names.
# A wrapper can supply the stamp it will also pass to xcodebuild.
info="${BUILD_INFO:-$("$script_dir/buildinfo.sh")}"
while IFS='=' read -r key value; do
case "$key" in
BUILD_NUMBER|BUILD_SHA|BUILD_BRANCH|BUILD_DIRTY|BUILD_DIRTY_FILES|BUILD_DIFF_ID|BUILD_DESCRIBE|BUILD_TAGGED|BUILD_TIMESTAMP|BUILD_CHANNEL)
printf -v "$key" '%s' "$value" ;;
esac
done <<< "$info"

name="${1:-$(basename "$PWD")}"
# MARKETING_VERSION lives in Config/Shared.xcconfig (standard) or, in repos not
# yet migrated, project.yml. Only read files that exist: sed exits non-zero on a
# missing file, and under `set -e`/`pipefail` that would kill the banner
# silently on exactly the repos that have not adopted the xcconfig yet.
vfiles=""
for f in Config/Shared.xcconfig project.yml; do [ -f "$f" ] && vfiles="$vfiles $f"; done
version=""
if [ -n "$vfiles" ]; then
# shellcheck disable=SC2086
version=$(sed -nE 's/^[[:space:]]*MARKETING_VERSION[[:space:]]*[=:][[:space:]]*"?([0-9.]+)"?.*/\1/p' \
$vfiles 2>/dev/null | head -1 || true)
fi
version="${version:-0.0.0}"

if [ -t 1 ]; then bold=$'\033[1m'; dim=$'\033[2m'; amber=$'\033[33m'; off=$'\033[0m'
else bold=""; dim=""; amber=""; off=""; fi

printf '%s▸ %s %s (%s)%s\n' "$bold" "$name" "$version" "$BUILD_NUMBER" "$off"
if [ -n "$BUILD_SHA" ]; then
printf ' commit %s branch %s\n' "$BUILD_SHA" "$BUILD_BRANCH"
else
printf ' commit %s(none recorded — no repository)%s\n' "$dim" "$off"
fi
if [ "$BUILD_DIRTY" = YES ]; then
printf ' tree %s⚠ DIRTY — %s files (diff %s)%s\n' "$amber" "$BUILD_DIRTY_FILES" "$BUILD_DIFF_ID" "$off"
printf ' %sthis build matches no commit and cannot be reproduced%s\n' "$amber" "$off"
elif [ "$BUILD_TAGGED" = YES ]; then
printf ' tree clean · %s\n' "$BUILD_DESCRIBE"
else
printf ' tree clean · %s%s%s\n' "$dim" "${BUILD_DESCRIBE:-untagged}" "$off"
fi
31 changes: 31 additions & 0 deletions scripts/build_identity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Shared Fastlane adapter. Capture once, then reuse these settings for every
# artifact in a lane. Empty BUILD_NUMBER input means generate, never build 0.
require "open3"
require "shellwords"
require "time"

module BuildIdentity
def self.settings(channel)
root = File.expand_path("..", __dir__)
output, result = Open3.capture2(
{ "BUILD_CHANNEL" => channel }, "bash", File.join(__dir__, "buildinfo.sh"), chdir: root
)
raise "Could not generate build identity" unless result.success?
values = output.lines.map(&:strip).reject(&:empty?)
override = ENV["BUILD_NUMBER"].to_s
unless override.empty?
raise "Invalid BUILD_NUMBER: #{override}" unless override.match?(/\A[0-9]{4}\.[0-9]{4}\.[0-9]{4}\z/)
timestamp = ENV["BUILD_TIMESTAMP"].to_s
timestamp = Time.strptime(override + " +0000", "%Y.%m%d.%H%M %z").utc.iso8601 if timestamp.empty?
raise "BUILD_TIMESTAMP differs from BUILD_NUMBER" unless Time.iso8601(timestamp).utc.strftime("%Y.%m%d.%H%M") == override
values.reject! { |value| value.start_with?("BUILD_NUMBER=", "BUILD_TIMESTAMP=") }
values << "BUILD_NUMBER=#{override}"
values << "BUILD_TIMESTAMP=#{timestamp}"
end
values
end

def self.xcargs(channel)
settings(channel).map(&:shellescape).join(" ")
end
end
56 changes: 56 additions & 0 deletions scripts/buildinfo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Emits xcodebuild build-setting arguments describing the tree being built.
#
# xcodebuild ... $(Scripts/buildinfo.sh)
#
# BUILD_CHANNEL local (default) | ci | testflight | release
# BUILD_PROVENANCE full (default) | minimal — `minimal` omits SHA and branch,
# set automatically for the `release` channel so a public
# binary carries nothing about a private repo.
set -euo pipefail

channel="${BUILD_CHANNEL:-local}"
provenance="${BUILD_PROVENANCE:-$([ "$channel" = release ] && echo minimal || echo full)}"

# Absent or unreadable repository (source export, artefact-only CI job) must
# degrade to empty provenance, never abort the build. `set -e` plus `pipefail`
# will kill an assignment whose pipeline fails, so every git call is guarded.
sha=""; branch=""; describe=""; diffid=""; tagged=NO; n=0

if git rev-parse --git-dir >/dev/null 2>&1 && git rev-parse HEAD >/dev/null 2>&1; then
sha=$(git rev-parse --short=8 HEAD)
branch=$(git rev-parse --abbrev-ref HEAD)
n=$(git status --porcelain | wc -l | tr -d ' ')
describe=$(git describe --tags --always --dirty 2>/dev/null || echo "")
if git describe --tags --exact-match --match 'v[0-9]*' HEAD >/dev/null 2>&1; then tagged=YES; fi
# Hash tracked changes AND untracked content — `git diff HEAD` alone ignores
# untracked files, so a tree dirty only with new files would otherwise get
# the constant SHA-256 of empty input (e3b0c442) on every build.
if [ "$n" -gt 0 ]; then
diffid=$( { git diff HEAD; git status --porcelain;
git ls-files --others --exclude-standard -z \
| xargs -0 shasum -a 256 2>/dev/null || true;
} | shasum -a 256 | cut -c1-8 )
fi
fi

# `release` builds ship no repository detail. Keys stay present but empty so
# the plist shape is identical across channels; BuildInfo.swift treats an empty
# value as absent.
if [ "$provenance" = minimal ]; then sha=""; branch=""; describe=""; diffid=""; fi

# Capture the clock once: the number and timestamp must describe the same
# instant even when this invocation straddles a UTC minute/day boundary.
stamp=$(date -u '+%Y.%m%d.%H%M|%Y-%m-%dT%H:%M:%SZ')
cat <<EOF
BUILD_NUMBER=${stamp%%|*}
BUILD_SHA=$sha
BUILD_BRANCH=$branch
BUILD_DIRTY=$([ "$n" -gt 0 ] && echo YES || echo NO)
BUILD_DIRTY_FILES=$n
BUILD_DIFF_ID=$diffid
BUILD_DESCRIBE=$describe
BUILD_TAGGED=$tagged
BUILD_TIMESTAMP=${stamp#*|}
BUILD_CHANNEL=$channel
EOF
103 changes: 103 additions & 0 deletions scripts/prepare-build-info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Capture a scheme identity, then write derived source plists before Xcode signs them.

The scheme pre-action runs outside the build-phase sandbox and reads Git once.
Each shipping target consumes the same captured identity inside its sandbox.
Neither tracked plists nor signed products are modified.
"""
import os
from pathlib import Path
import plistlib
import re
import subprocess
import sys

PLIST_KEYS = {
"BuildSHA": "BUILD_SHA", "BuildBranch": "BUILD_BRANCH",
"BuildDirty": "BUILD_DIRTY", "BuildDirtyFiles": "BUILD_DIRTY_FILES",
"BuildDiffID": "BUILD_DIFF_ID", "BuildDescribe": "BUILD_DESCRIBE",
"BuildTagged": "BUILD_TAGGED", "BuildTimestamp": "BUILD_TIMESTAMP",
"BuildChannel": "BUILD_CHANNEL",
}


def channel():
requested = os.environ.get("BUILD_CHANNEL", "")
if requested and requested != "xcode":
return requested
return "release" if os.environ.get("ACTION") == "install" else "local"


def main():
session = Path(os.environ["PROJECT_TEMP_DIR"]) / "BuildIdentity.plist"
if sys.argv[1] == "session":
# A failed capture must never leave the previous build's identity usable.
session.unlink(missing_ok=True)
root = Path(__file__).resolve().parents[1]
env = dict(os.environ, BUILD_CHANNEL=channel())
result = subprocess.run(["bash", str(Path(__file__).with_name("buildinfo.sh"))],
cwd=root, env=env, check=True, capture_output=True, text=True)
values = dict(line.split("=", 1) for line in result.stdout.splitlines())
session.parent.mkdir(parents=True, exist_ok=True)
session.write_bytes(plistlib.dumps(values))
return

if not session.is_file():
raise RuntimeError("Build identity is missing. Build or Archive using a shared app scheme.")
values = plistlib.loads(session.read_bytes())
# A wrapper can capture once for multiple platform invocations. Only a
# complete stamp overrides the session; xcconfig defaults are sentinels.
if os.environ.get("BUILD_TIMESTAMP") and os.environ.get("BUILD_NUMBER") not in (None, "", "0"):
values.update({key: os.environ.get(key, "") for key in values})
build = os.environ.get("CURRENT_PROJECT_VERSION", "0")
if build in ("", "0"):
build = values["BUILD_NUMBER"]
version = os.environ["MARKETING_VERSION"]
if not re.fullmatch(r"[1-9][0-9]*(?:\.[0-9]+){0,2}", build):
raise ValueError(f"Invalid build number: {build!r}")
if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){0,2}", version):
raise ValueError(f"Invalid marketing version: {version!r}")
# ACTION is authoritative in the target's archive build even if Xcode's
# scheme pre-action did not expose its archive context.
if os.environ.get("ACTION") == "install" and os.environ.get("BUILD_CHANNEL", "") in ("", "xcode"):
values["BUILD_CHANNEL"] = "release"
if os.environ.get("BUILD_PROVENANCE", "minimal") == "minimal":
for key in ("BUILD_SHA", "BUILD_BRANCH", "BUILD_DESCRIBE", "BUILD_DIFF_ID"):
values[key] = ""

source = os.environ.get("BUILD_IDENTITY_SOURCE_PLIST", "")
info = plistlib.loads(Path(source).read_bytes()) if source else {}
info["CFBundleShortVersionString"] = version
info["CFBundleVersion"] = build
info.update({key: values.get(setting, "") for key, setting in PLIST_KEYS.items()})
for key, setting in {
"CFBundleName": "PRODUCT_NAME", "CFBundleIdentifier": "PRODUCT_BUNDLE_IDENTIFIER",
"CFBundleExecutable": "EXECUTABLE_NAME", "CFBundlePackageType": "PRODUCT_BUNDLE_PACKAGE_TYPE",
}.items():
info.setdefault(key, os.environ.get(setting, f"$({setting})"))
info.setdefault("CFBundleDevelopmentRegion", os.environ.get("DEVELOPMENT_LANGUAGE", "en"))
info.setdefault("CFBundleInfoDictionaryVersion", "6.0")
for setting, value in os.environ.items():
if not setting.startswith("INFOPLIST_KEY_") or setting.endswith("_Generation"):
continue
key = setting.removeprefix("INFOPLIST_KEY_")
if key.startswith("UISupportedInterfaceOrientations"):
key = key.replace("_iPad", "~ipad").replace("_iPhone", "~iphone")
info[key] = value.split()
else:
info[key] = {"YES": True, "NO": False}.get(value, value)
if os.environ.get("PLATFORM_NAME") in ("iphoneos", "iphonesimulator", "appletvos", "appletvsimulator"):
if os.environ.get("INFOPLIST_KEY_UIApplicationSceneManifest_Generation") == "YES":
info.setdefault("UIApplicationSceneManifest", {"UIApplicationSupportsMultipleScenes": True, "UISceneConfigurations": {}})
if os.environ.get("INFOPLIST_KEY_UILaunchScreen_Generation") == "YES":
info.setdefault("UILaunchScreen", {})
if info.get("CFBundlePackageType") == "APPL":
info.setdefault("LSRequiresIPhoneOS", True)
output = Path(os.environ["DERIVED_FILE_DIR"]) / "BuildIdentity-Info.plist"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(plistlib.dumps(info))
print(f"{os.environ.get('PRODUCT_NAME', 'App')} {version} ({build})")


if __name__ == "__main__":
main()
Loading
Loading