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
42 changes: 41 additions & 1 deletion .github/workflows/kilo-app-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,25 @@ jobs:
}
download "$IOS_URL" artifacts/app.ipa "iOS"
download "$ANDROID_URL" artifacts/app.aab "Android"
# The Podfile.lock this iOS build resolved, uploaded by eas.json
# buildArtifactPaths. Its URL is signed, so it is read from urls.txt
# here and never exported or echoed. EAS uploads a single build artifact
# as the file itself and several as one tar.gz, so both are accepted.
download "$(sed -n '3p' urls.txt)" artifacts/ios-build-artifacts "iOS build artifacts"
if gzip -t artifacts/ios-build-artifacts 2>/dev/null; then
MEMBER=$(tar -tzf artifacts/ios-build-artifacts | grep -E '(^|/)Podfile\.lock$' || true)
if [ "$(printf '%s' "$MEMBER" | grep -c '')" -ne 1 ]; then
echo "::error::the iOS build artifacts archive must hold exactly one Podfile.lock, found: ${MEMBER:-none}"
exit 1
fi
tar -xzf artifacts/ios-build-artifacts -O "$MEMBER" > artifacts/Podfile.lock
else
mv artifacts/ios-build-artifacts artifacts/Podfile.lock
fi
if ! grep -q '^PODS:' artifacts/Podfile.lock; then
echo "::error::the iOS build artifacts carry no Podfile.lock with a PODS: section"
exit 1
fi

- name: Setup Java
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
Expand All @@ -292,7 +311,7 @@ jobs:
# first submission: a build that cannot be documented is never submitted.
- name: Generate SBOMs
working-directory: apps/mobile
run: node ../../scripts/mobile-sbom.mjs --ipa artifacts/app.ipa --aab artifacts/app.aab --build-json build.json --out-dir artifacts
run: node ../../scripts/mobile-sbom.mjs --ipa artifacts/app.ipa --aab artifacts/app.aab --build-json build.json --podfile-lock artifacts/Podfile.lock --out-dir artifacts

# The retained second copy, matching sbom.yml's cloud-sbom-<sha> pattern.
- name: Upload SBOMs
Expand Down Expand Up @@ -331,8 +350,22 @@ jobs:
# same commit pushes a second, distinct marker instead of failing on a
# name that already exists. An annotated tag needs a tagger identity; the
# runner has none.
# Both tag pushes use KILO_APP_RELEASE_TOKEN (workflow scope): GitHub
# refuses a GITHUB_TOKEN tag push when the tagged commit's
# .github/workflows differs from every branch tip, which happens whenever
# a workflow change merges while this job runs. The token reaches only
# these two steps, never .git/config, so pnpm and eas-cli cannot read it.
# GIT_CONFIG_* is command-line level: the empty value drops the persisted
# GITHUB_TOKEN header, and the second entry sends the release token.
- name: Mark the iOS upload
env:
RELEASE_TOKEN: ${{ secrets.KILO_APP_RELEASE_TOKEN }}
run: |
AUTH=$(printf 'x-access-token:%s' "$RELEASE_TOKEN" | base64 -w0)
echo "::add-mask::$AUTH"
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0=
export GIT_CONFIG_KEY_1=http.https://github.com/.extraheader GIT_CONFIG_VALUE_1="AUTHORIZATION: basic $AUTH"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
MARKER="kilo-app-upload/$(date -u +%Y-%m-%d)-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
Expand All @@ -354,7 +387,14 @@ jobs:
# Release detection only: the next run reads this to find the last release.
# The upload cap never counts it (a partial run writes no release tag).
- name: Tag release
env:
RELEASE_TOKEN: ${{ secrets.KILO_APP_RELEASE_TOKEN }}
run: |
AUTH=$(printf 'x-access-token:%s' "$RELEASE_TOKEN" | base64 -w0)
echo "::add-mask::$AUTH"
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0=
export GIT_CONFIG_KEY_1=http.https://github.com/.extraheader GIT_CONFIG_VALUE_1="AUTHORIZATION: basic $AUTH"
TAG="kilo-app-release/$(date -u +%Y-%m-%d)-$(git rev-parse --short=7 HEAD)"
# The tag name is deterministic, so a rerun of this commit recomputes
# the tag a failed attempt already pushed. Reuse it: the failure that
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
"production": {
"extends": "base",
"autoIncrement": true,
"environment": "production"
"environment": "production",
"ios": {
"buildArtifactPaths": ["ios/Podfile.lock"]
}
},
"preview": {
"extends": "base",
Expand Down
40 changes: 24 additions & 16 deletions docs/sbom.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ scan does not reproduce their component list). SBOMs are **never committed** to
A mobile SBOM is an inventory of what the tooling can observe, and that observation has edges. Read
these limits before relying on a count:

- **Statically linked iOS pods are invisible.** Pods compiled into the app binary leave no file or
load-command trace, so the IPA's pod list is a **lower bound**, not the resolved graph.
- **A dynamic framework the build links but does not report is invisible.** If the linker records no
load command and no framework file ships, nothing in the IPA names it.
- **The iOS pod list is what CocoaPods resolved for the build.** The build's `Podfile.lock` names
every pod, statically or dynamically linked, but a subspec is folded into its pod and the
`SPEC CHECKSUMS` hash identifies the podspec, not the compiled code in the IPA.
- **A framework outside CocoaPods that the build links but does not report is invisible.** If it is
not in the `Podfile.lock`, the linker records no load command, and no framework file ships,
nothing names it.
- **The Android metadata lists resolved Gradle/Maven modules.** It can omit dependencies that were
never resolved or that do not come from Maven, and it names modules, not the classes inside them.
- **The JavaScript list is the declared production closure.** It comes from `pnpm-lock.yaml`, so a
Expand Down Expand Up @@ -73,28 +75,34 @@ artifact you hold to confirm the SBOM describes those bytes.
- **npm (both platforms)** — the production dependency closure of `apps/mobile` from `pnpm-lock.yaml`.
It is scoped from `importers['apps/mobile'].dependencies` (plus `optionalDependencies`) and walked
through `snapshots`, following `link:`/`file:` entries into their workspace importers.
- **iOS** — the IPA's Mach-O `LC_LOAD_DYLIB`/weak/reexport load commands plus
`Payload/*.app/Frameworks/*`. The authoritative graph is `apps/mobile/ios/Podfile.lock`, but the
native project is generated by Expo CNG and is not in git (`git ls-files apps/mobile/ios` returns
0 files), and `pod install` needs macOS, which the release runner does not have, so the artifact is
the source. Load commands naming an OS-provided library (`/usr/lib/`, `/System/Library/`, including
`PrivateFrameworks`) are skipped: the OS supplies those, the IPA does not carry them, so they are
not listed as CocoaPods.
- **iOS** — the `Podfile.lock` EAS resolved for that exact build, plus a scan of the IPA. Expo CNG
generates `apps/mobile/ios` on the builder (nothing under it is in git), so the production profile
in `apps/mobile/eas.json` uploads `ios/Podfile.lock` through `buildArtifactPaths`, and the release
workflow downloads it from the build's `buildArtifactsUrl` next to the IPA. Every root pod under
`PODS:` becomes one component (subspecs such as `React-Core/Default` collapse into `React-Core`)
with its locked version and a `pkg:cocoapods/<Name>@<version>` purl; `kilo:sbom:ios-kind` is
`podfile-lock`. The `SPEC CHECKSUMS` value, when listed, is the `kilo:sbom:podspec-checksum`
property, not a component hash: it hashes the podspec, not the shipped code. This is the only source
for pods statically linked into the executable. The IPA scan adds the Mach-O
`LC_LOAD_DYLIB`/weak/reexport load commands plus `Payload/*.app/Frameworks/*` (`kilo:sbom:ios-kind`
`dylib-load-command` or `dynamic-framework`). Load commands naming an OS-provided library
(`/usr/lib/`, `/System/Library/`, including `PrivateFrameworks`) are skipped: the OS supplies
those, the IPA does not carry them, so they are not listed as CocoaPods. A missing or unreadable
`Podfile.lock` fails the release before anything is submitted.
- **Android** — the AAB's own `BUNDLE-METADATA/com.android.tools.build.libraries/dependencies.pb`,
written by the Android Gradle Plugin, plus `base/lib/**/*.so`.

## Reproducing the mobile coverage numbers

Re-measure the limits above on any release. Run both commands from the repository root; the paths
under `apps/mobile/` are the ones the release workflow leaves behind. For the iOS pod gap, point the
generator at a real `Podfile.lock` and it prints how many pods the lockfile declares, how many the
IPA shows, and the names of those it does not:
under `apps/mobile/` are the ones the release workflow leaves behind, including the build's
`Podfile.lock`:

```sh
node scripts/mobile-sbom.mjs \
--ipa apps/mobile/artifacts/app.ipa --aab apps/mobile/artifacts/app.aab \
--build-json apps/mobile/build.json --out-dir apps/mobile/artifacts \
--podfile-lock apps/mobile/ios/Podfile.lock
--build-json apps/mobile/build.json --podfile-lock apps/mobile/artifacts/Podfile.lock \
--out-dir apps/mobile/artifacts
```

For Android, compare what the AAB's own metadata carries against what syft reports on its own:
Expand Down
17 changes: 15 additions & 2 deletions scripts/inspect-mobile-artifacts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
* with bundletool, checks debug symbols, and prints a signed-artifact size
* table (JS bundles, fonts, and grammar modules). The --select mode validates
* the EAS build.json (every build FINISHED, one IOS and one ANDROID entry with
* an applicationArchiveUrl) and prints the two archive URLs, one per line.
* an applicationArchiveUrl, and an IOS buildArtifactsUrl) and prints three
* lines: the iOS archive URL, the Android archive URL, and the iOS build
* artifacts URL (the archive eas.json `buildArtifactPaths` uploads, which
* carries the build's Podfile.lock).
*
* Exits 1 with a clear message on any contract violation.
*/
Expand Down Expand Up @@ -135,6 +138,10 @@ function artifactUrl(build) {
return build?.artifacts?.applicationArchiveUrl ?? '';
}

function buildArtifactsUrl(build) {
return build?.artifacts?.buildArtifactsUrl ?? '';
}

function selectMode(buildJsonPath) {
const builds = parseBuildJson(buildJsonPath);
assertAllFinished(builds);
Expand All @@ -154,10 +161,16 @@ function selectMode(buildJsonPath) {
if (!androidUrl) {
failures.push('ANDROID build has no artifacts.applicationArchiveUrl');
}
const iosBuildArtifactsUrl = buildArtifactsUrl(ios);
if (!iosBuildArtifactsUrl) {
failures.push(
'IOS build has no artifacts.buildArtifactsUrl (eas.json build.production.ios.buildArtifactPaths must upload ios/Podfile.lock)'
);
}
if (failures.length > 0) {
reportAndExit();
}
process.stdout.write(`${iosUrl}\n${androidUrl}\n`);
process.stdout.write(`${iosUrl}\n${androidUrl}\n${iosBuildArtifactsUrl}\n`);
process.exit(0);
}

Expand Down
52 changes: 52 additions & 0 deletions scripts/inspect-mobile-artifacts.test.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { deflateRawSync } from 'node:zlib';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import {
checkResourceShrinking,
Expand Down Expand Up @@ -164,3 +166,53 @@ test('inspectJsBundles reports hasDebugId false when debug-id markers are absent
const result = withFixture([['index.jsbundle', INSPECT_JS_NEEDLES.join(' ')]], inspectJsBundles);
assert.equal(result.hasDebugId, false);
});

function runSelect(builds) {
const work = mkdtempSync(join(tmpdir(), 'kilo-select-test-'));
const buildJsonPath = join(work, 'build.json');
try {
writeFileSync(buildJsonPath, JSON.stringify(builds));
return spawnSync(
'node',
[
fileURLToPath(new URL('./inspect-mobile-artifacts.mjs', import.meta.url)),
'--select',
buildJsonPath,
],
{ encoding: 'utf8' }
);
} finally {
rmSync(work, { recursive: true, force: true });
}
}

test('--select prints the iOS, Android and iOS build artifacts URLs, and requires the last', () => {
const ios = {
platform: 'IOS',
status: 'FINISHED',
artifacts: {
applicationArchiveUrl: 'https://example.invalid/app.ipa',
buildArtifactsUrl: 'https://example.invalid/build-artifacts.tar.gz',
},
};
const android = {
platform: 'ANDROID',
status: 'FINISHED',
artifacts: { applicationArchiveUrl: 'https://example.invalid/app.aab' },
};

const selected = runSelect([android, ios]);
assert.equal(selected.status, 0, selected.stderr);
assert.equal(
selected.stdout,
'https://example.invalid/app.ipa\nhttps://example.invalid/app.aab\nhttps://example.invalid/build-artifacts.tar.gz\n'
);

const missing = runSelect([
{ ...ios, artifacts: { applicationArchiveUrl: ios.artifacts.applicationArchiveUrl } },
android,
]);
assert.equal(missing.status, 1);
assert.equal(missing.stdout, '');
assert.match(missing.stderr, /IOS build has no artifacts\.buildArtifactsUrl/);
});
Loading
Loading