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
4 changes: 0 additions & 4 deletions apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,6 @@ async function generateMobileThirdPartyLicenses() {
const manifest = await generateThirdPartyLicenseManifest({
configFile: path.join(workspaceRoot, "third-party-licenses.config.json"),
packageManifests: [{ bundle: "mobile", path: path.join(__dirname, "package.json") }],
allowMissingGeneratedNotices:
process.env.NODE_ENV !== "production" &&
process.env.EAS_BUILD !== "true" &&
process.env.T3CODE_LICENSES_STRICT !== "1",
});

await Promise.all([
Expand Down
14 changes: 6 additions & 8 deletions docs/internals/open-source-licenses.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,10 @@ Expo and React Native. That is conservative and can include build tooling that i
the final JavaScript bundle, but it avoids dropping a notice when platform bundling changes.

The build fails when a collected package has no distributable license identifier or contains no
license or notice text. Generated notices use license templates from the pinned SPDX License List.
Strict web and EAS builds download a missing template into the gitignored `.generated/` cache;
`pnpm licenses:sync` can warm that cache explicitly. Local web and Metro development do not make a
network request and omit generated rows until the cache exists. This keeps dev startup optional
while preventing incomplete release artifacts.
license or notice text. Generated notices use license templates from the pinned SPDX License List,
committed under `third-party-licenses/spdx/`. Builds never fetch them, so a network blip cannot
fail a build. A build fails when a configured template is missing; run `vp run licenses:sync` to
download it, then commit the new file.

## Custom notices and package overrides

Expand Down Expand Up @@ -103,6 +102,5 @@ The `@react-grab/cli` override uses the root React Grab repository's MIT license
npm archive omits both its license field and license file. Keep the override until the published
CLI package carries that metadata itself.

Generated mobile files live under `apps/mobile/.generated/`, while fetched SPDX templates live
under the repository `.generated/` directory. Both are ignored. Do not commit or edit them;
updating dependencies or configuration is enough for the next strict build to refresh the output.
Generated mobile files live under `apps/mobile/.generated/` and are ignored. Do not commit or edit
them; updating dependencies or configuration is enough for the next build to refresh the output.
18 changes: 6 additions & 12 deletions scripts/lib/third-party-licenses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,12 @@ describe("third-party license generation", () => {
});
});

it("renders generated notices from the ignored SPDX cache", async () => {
it("renders generated notices from the committed SPDX templates", async () => {
const fixture = await createFixture();
await writeJson(
NodePath.join(fixture.root, ".generated/third-party-licenses/spdx/v3.28.0/MIT.json"),
{
licenseId: "MIT",
licenseText: "MIT License\n\nCopyright (c) <year> <copyright holders>\n\nPermission text",
},
);
await writeJson(NodePath.join(fixture.root, "third-party-licenses/spdx/v3.28.0/MIT.json"), {
licenseId: "MIT",
licenseText: "MIT License\n\nCopyright (c) <year> <copyright holders>\n\nPermission text",
});
await writeJson(fixture.configFile, {
customNotices: [
{
Expand Down Expand Up @@ -364,10 +361,7 @@ describe("third-party license generation", () => {
await NodeFSP.rm(NodePath.join(fixture.dependencyRoot, "LICENSE"));
for (const licenseId of ["Apache-2.0", "BSD-3-Clause"]) {
await writeJson(
NodePath.join(
fixture.root,
`.generated/third-party-licenses/spdx/v3.28.0/${licenseId}.json`,
),
NodePath.join(fixture.root, `third-party-licenses/spdx/v3.28.0/${licenseId}.json`),
{ licenseId, licenseText: `${licenseId} terms` },
);
}
Expand Down
102 changes: 32 additions & 70 deletions scripts/lib/third-party-licenses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { Plugin } from "vite-plus";
const THIRD_PARTY_LICENSES_FILE_NAME = "third-party-licenses.json";
const SPDX_LICENSE_LIST_VERSION = "v3.28.0";
const SPDX_LICENSE_LIST_REVISION = "c4a7237ec8f4654e867546f9f409749300f1bf4c";
const GENERATED_NOTICE_CACHE_DIRECTORY = ".generated/third-party-licenses/spdx";
const SPDX_TEMPLATE_DIRECTORY = "third-party-licenses/spdx";

export interface ThirdPartyLicenseEntry {
readonly bundles: ReadonlyArray<string>;
Expand Down Expand Up @@ -312,10 +312,10 @@ async function readConfig(configFile: string | URL | undefined): Promise<{
};
}

function spdxLicenseCachePath(configDirectory: string, licenseId: string): string {
function spdxTemplatePath(configDirectory: string, licenseId: string): string {
return NodePath.join(
configDirectory,
GENERATED_NOTICE_CACHE_DIRECTORY,
SPDX_TEMPLATE_DIRECTORY,
SPDX_LICENSE_LIST_VERSION,
`${licenseId}.json`,
);
Expand All @@ -333,12 +333,12 @@ function decodeSpdxLicenseDetails(value: unknown, expectedLicenseId: string): Sp
return { licenseId: expectedLicenseId, licenseText: value.licenseText.trim() };
}

async function readCachedSpdxLicense(
async function readSpdxLicense(
configDirectory: string,
licenseId: string,
): Promise<SpdxLicenseDetails | null> {
try {
const source = await NodeFSP.readFile(spdxLicenseCachePath(configDirectory, licenseId), "utf8");
const source = await NodeFSP.readFile(spdxTemplatePath(configDirectory, licenseId), "utf8");
return decodeSpdxLicenseDetails(JSON.parse(source) as unknown, licenseId);
} catch (error) {
const code = isRecord(error) && typeof error.code === "string" ? error.code : null;
Expand All @@ -347,10 +347,7 @@ async function readCachedSpdxLicense(
}
}

async function downloadSpdxLicense(
configDirectory: string,
licenseId: string,
): Promise<SpdxLicenseDetails> {
async function downloadSpdxLicense(configDirectory: string, licenseId: string): Promise<void> {
const url = `https://raw.githubusercontent.com/spdx/license-list-data/${SPDX_LICENSE_LIST_REVISION}/json/details/${encodeURIComponent(licenseId)}.json`;
const response = await fetch(url);
if (!response.ok) {
Expand All @@ -359,20 +356,9 @@ async function downloadSpdxLicense(
);
}
const details = decodeSpdxLicenseDetails((await response.json()) as unknown, licenseId);
const cachePath = spdxLicenseCachePath(configDirectory, licenseId);
await NodeFSP.mkdir(NodePath.dirname(cachePath), { recursive: true });
await NodeFSP.writeFile(cachePath, `${JSON.stringify(details)}\n`, "utf8");
return details;
}

async function resolveSpdxLicense(
configDirectory: string,
licenseId: string,
allowMissing: boolean,
): Promise<SpdxLicenseDetails | null> {
const cached = await readCachedSpdxLicense(configDirectory, licenseId);
if (cached || allowMissing) return cached;
return downloadSpdxLicense(configDirectory, licenseId);
const templatePath = spdxTemplatePath(configDirectory, licenseId);
await NodeFSP.mkdir(NodePath.dirname(templatePath), { recursive: true });
await NodeFSP.writeFile(templatePath, `${JSON.stringify(details, null, 2)}\n`, "utf8");
}

function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText: string): string {
Expand All @@ -397,17 +383,19 @@ function renderGeneratedNotice(config: GeneratedNoticeConfigEntry, licenseText:
async function generatedNoticeText(
configs: ReadonlyArray<GeneratedNoticeConfigEntry>,
configDirectory: string,
allowMissing: boolean,
): Promise<string | null> {
): Promise<string> {
const sections = await Promise.all(
configs.map(async (config) => {
const details = await resolveSpdxLicense(configDirectory, config.licenseId, allowMissing);
return details ? renderGeneratedNotice(config, details.licenseText) : null;
const details = await readSpdxLicense(configDirectory, config.licenseId);
if (!details) {
throw new Error(
`SPDX license template ${config.licenseId} is missing. Run \`vp run licenses:sync\` and commit ${SPDX_TEMPLATE_DIRECTORY}.`,
);
}
return renderGeneratedNotice(config, details.licenseText);
}),
);
return sections.some((section) => section === null)
? null
: (sections as ReadonlyArray<string>).join("\n\n---\n\n");
return sections.join("\n\n---\n\n");
}

function configuredGeneratedNotices(
Expand All @@ -426,7 +414,13 @@ async function syncConfiguredGeneratedNotices(
const licenseIds = [
...new Set(configuredGeneratedNotices(config).map((notice) => notice.licenseId)),
].sort((left, right) => left.localeCompare(right));
await Promise.all(licenseIds.map((licenseId) => resolveSpdxLicense(directory, licenseId, false)));
await Promise.all(
licenseIds.map(async (licenseId) => {
if (!(await readSpdxLicense(directory, licenseId))) {
await downloadSpdxLicense(directory, licenseId);
}
}),
);
}

export async function syncThirdPartyLicenseNotices(configFile: string | URL): Promise<void> {
Expand Down Expand Up @@ -844,8 +838,7 @@ async function packageEntry(
configDirectory: string,
packageNotices: Map<string, Promise<string | null>>,
repositoryNotices: ReadonlyMap<string, string>,
allowMissingGeneratedNotices: boolean,
): Promise<ThirdPartyLicenseEntry | null> {
): Promise<ThirdPartyLicenseEntry> {
const name =
typeof collected.packageJson.name === "string"
? collected.packageJson.name
Expand All @@ -867,19 +860,14 @@ async function packageEntry(

const repositoryKey = repositoryNoticeKey(collected.packageJson, license);
const noticeText = override?.generatedNotices
? await generatedNoticeText(
override.generatedNotices,
configDirectory,
allowMissingGeneratedNotices,
)
? await generatedNoticeText(override.generatedNotices, configDirectory)
: override?.noticeFile
? (
await NodeFSP.readFile(NodePath.resolve(configDirectory, override.noticeFile), "utf8")
).trim()
: ((await packageNoticeText(collected.packageRoot, packageNotices)) ??
(repositoryKey ? repositoryNotices.get(repositoryKey) : undefined));
if (!noticeText) {
if (override?.generatedNotices && allowMissingGeneratedNotices) return null;
throw new Error(
`${name}@${version} does not include a license or notice file. Add a package override with "noticeFile" or "generatedNotice" in the third-party license config.`,
);
Expand All @@ -900,7 +888,6 @@ async function customEntries(
config: ThirdPartyLicensesConfig,
configDirectory: string,
includedBundles: ReadonlySet<string>,
allowMissingGeneratedNotices: boolean,
): Promise<ReadonlyArray<ThirdPartyLicenseEntry>> {
const deviceToolVersions: unknown = JSON.parse(
await NodeFSP.readFile(
Expand All @@ -920,11 +907,7 @@ async function customEntries(
)
.map(async (notice) => {
const noticeText = notice.generatedNotices
? await generatedNoticeText(
notice.generatedNotices,
configDirectory,
allowMissingGeneratedNotices,
)
? await generatedNoticeText(notice.generatedNotices, configDirectory)
: (
await Promise.all(
notice.noticeFiles!.map(async (noticeFile) => {
Expand All @@ -938,10 +921,6 @@ async function customEntries(
}),
)
).join("\n\n---\n\n");
if (noticeText === null) {
if (allowMissingGeneratedNotices) return null;
throw new Error(`Could not generate custom third-party notice "${notice.name}".`);
}
if (noticeText.length === 0) {
throw new Error(`Custom third-party notice "${notice.name}" is empty.`);
}
Expand All @@ -961,7 +940,7 @@ async function customEntries(
};
}),
);
return entries.flatMap((entry) => (entry ? [entry] : []));
return entries;
}

function entrySort(left: ThirdPartyLicenseEntry, right: ThirdPartyLicenseEntry): number {
Expand Down Expand Up @@ -990,15 +969,11 @@ export async function generateThirdPartyLicenseManifest(input: {
readonly packageManifests: ReadonlyArray<ThirdPartyLicensePackageManifest>;
readonly bundledModuleIds?: ReadonlyArray<string>;
readonly bundleName?: string;
readonly allowMissingGeneratedNotices?: boolean;
}): Promise<ThirdPartyLicenseManifest> {
const [{ config, directory }, collection] = await Promise.all([
readConfig(input.configFile),
collectProductionDependencyPackages(input.packageManifests),
]);
if (!(input.allowMissingGeneratedNotices ?? false)) {
await syncConfiguredGeneratedNotices(config, directory);
}
if (input.bundledModuleIds && input.bundleName) {
await addBundledModulePackages(collection, input.bundledModuleIds, input.bundleName);
}
Expand All @@ -1008,14 +983,7 @@ export async function generateThirdPartyLicenseManifest(input: {

const packageEntryResults = await Promise.allSettled(
[...collection.byIdentity.values()].map((collected) =>
packageEntry(
collected,
config,
directory,
packageNotices,
repositoryNotices,
input.allowMissingGeneratedNotices ?? false,
),
packageEntry(collected, config, directory, packageNotices, repositoryNotices),
),
);
const failures = packageEntryResults.flatMap((result) =>
Expand All @@ -1029,16 +997,11 @@ export async function generateThirdPartyLicenseManifest(input: {
);
}
const packageEntries = packageEntryResults.flatMap((result) =>
result.status === "fulfilled" && result.value ? [result.value] : [],
result.status === "fulfilled" ? [result.value] : [],
);
const includedBundles = new Set(input.packageManifests.map((manifest) => manifest.bundle));
if (input.bundleName) includedBundles.add(input.bundleName);
const manualEntries = await customEntries(
config,
directory,
includedBundles,
input.allowMissingGeneratedNotices ?? false,
);
const manualEntries = await customEntries(config, directory, includedBundles);
const entries = [...packageEntries, ...manualEntries].sort(entrySort);
assertUniqueEntries(entries);
return {
Expand Down Expand Up @@ -1074,7 +1037,6 @@ export function thirdPartyLicensesPlugin(options: ThirdPartyLicensesPluginOption
manifestPromise ??= generateThirdPartyLicenseManifest({
packageManifests: options.packageManifests,
bundleName: options.bundleName,
allowMissingGeneratedNotices: true,
...(options.configFile !== undefined ? { configFile: options.configFile } : {}),
}).catch((error: unknown) => {
manifestPromise = null;
Expand Down
Loading
Loading