Skip to content
Open
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
29 changes: 26 additions & 3 deletions .github/actions/version-matrix/src/matrices/node/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
needsToRunMatrixGeneration,
updateCacheState,
} from '../../shared/cache.ts';
import { resolveCamoufoxPlaywrightVersions } from '../../shared/camoufox.ts';
import {
emptyMatrix,
latestNodeVersion,
Expand All @@ -30,12 +31,24 @@ let latestApifyVersion = apifyVersions.at(-1)!;
let latestCrawleeVersion = crawleeVersions.at(-1)!;
let latestCamoufoxVersion = camoufoxVersions.at(-1)!;

// Camoufox does not support every Playwright release, so its images are built with the newest Playwright versions it
// declares support for, picked from all releases instead of just the last five - otherwise the camoufox image would
// drop out of the matrix entirely once the last five releases are all unsupported.
const { range: camoufoxPlaywrightRange, versions: supportedCamoufoxPlaywrightVersions } =
await resolveCamoufoxPlaywrightVersions('node', latestCamoufoxVersion, playwrightVersions);
const camoufoxPlaywrightVersions = supportedCamoufoxPlaywrightVersions.slice(shouldUseLastFive ? -5 : -1);
const latestCamoufoxPlaywrightVersion = camoufoxPlaywrightVersions.at(-1);

const certificatesUpdatedAt = await getCertificatesUpdatedAt();

console.error('Latest five versions', latestFivePlaywrightVersions);
console.error('Latest apify version', latestApifyVersion);
console.error('Latest crawlee version', latestCrawleeVersion);
console.error('Latest camoufox version', latestCamoufoxVersion);
console.error(
`Playwright versions for camoufox (camoufox-js@${latestCamoufoxVersion} supports ${camoufoxPlaywrightRange ?? 'nothing we could resolve'})`,
camoufoxPlaywrightVersions.length ? camoufoxPlaywrightVersions : '(none, camoufox images will be skipped)',
);
console.error('Node runtime versions', nodeRuntimeVersions);
console.error('Certificates updated at', certificatesUpdatedAt || '(not available)');

Expand All @@ -56,6 +69,7 @@ const cacheParams: CacheValues = {
APIFY_VERSION: [latestApifyVersion],
CRAWLEE_VERSION: [latestCrawleeVersion],
CAMOUFOX_VERSION: [latestCamoufoxVersion],
CAMOUFOX_PLAYWRIGHT_VERSION: camoufoxPlaywrightVersions,
CERTIFICATES_UPDATED_AT: certificatesUpdatedAt ? [certificatesUpdatedAt] : [],
};

Expand Down Expand Up @@ -95,16 +109,25 @@ const matrix = {
};

for (const nodeVersion of supportedNodeVersions) {
for (const playwrightVersion of latestFivePlaywrightVersions) {
for (const imageName of imageNames) {
for (const imageName of imageNames) {
const isCamoufoxImage = imageName === 'node-playwright-camoufox';

// The camoufox image only supports a subset of the Playwright versions, so it gets its own version list, along
// with its own "latest" version - otherwise it would never receive the moving tags (e.g. `:24`, `:latest`).
const imagePlaywrightVersions = isCamoufoxImage ? camoufoxPlaywrightVersions : latestFivePlaywrightVersions;
const latestImagePlaywrightVersion = isCamoufoxImage
? latestCamoufoxPlaywrightVersion
: latestPlaywrightVersion;

for (const playwrightVersion of imagePlaywrightVersions) {
matrix.include.push({
'image-name': imageName,
'node-version': nodeVersion,
'playwright-version': playwrightVersion,
'apify-version': `^${latestApifyVersion}`,
'crawlee-version': `^${latestCrawleeVersion}`,
'camoufox-version': `^${latestCamoufoxVersion}`,
'is-latest': playwrightVersion === latestPlaywrightVersion ? 'true' : 'false',
'is-latest': playwrightVersion === latestImagePlaywrightVersion ? 'true' : 'false',
'latest-node-version': latestNodeVersion,
'supports-arm64': arm64UnsupportedImages.has(imageName) ? 'false' : 'true',
});
Expand Down
37 changes: 30 additions & 7 deletions .github/actions/version-matrix/src/matrices/python/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
needsToRunMatrixGeneration,
updateCacheState,
} from '../../shared/cache.ts';
import { resolveCamoufoxPlaywrightVersions } from '../../shared/camoufox.ts';
import {
emptyMatrix,
latestPythonVersion,
Expand Down Expand Up @@ -38,11 +39,23 @@ const latestFivePlaywrightVersions = playwrightVersions.slice(shouldUseLastFive
const latestPlaywrightVersion = latestFivePlaywrightVersions.at(-1)!;
const latestCamoufoxVersion = camoufoxVersions.at(-1)!;

// Camoufox does not support every Playwright release, so its images are built with the newest Playwright versions it
// declares support for, picked from all releases instead of just the last five - otherwise the camoufox image would
// drop out of the matrix entirely once the last five releases are all unsupported.
const { range: camoufoxPlaywrightRange, versions: supportedCamoufoxPlaywrightVersions } =
await resolveCamoufoxPlaywrightVersions('python', latestCamoufoxVersion, playwrightVersions);
const camoufoxPlaywrightVersions = supportedCamoufoxPlaywrightVersions.slice(shouldUseLastFive ? -5 : -1);
const latestCamoufoxPlaywrightVersion = camoufoxPlaywrightVersions.at(-1);

const certificatesUpdatedAt = await getCertificatesUpdatedAt();

console.error('Last five versions:', latestFivePlaywrightVersions);
console.error('Latest playwright version:', latestPlaywrightVersion);
console.error('Latest camoufox version:', latestCamoufoxVersion);
console.error(
`Playwright versions for camoufox (camoufox==${latestCamoufoxVersion} supports ${camoufoxPlaywrightRange ?? 'nothing we could resolve'}):`,
camoufoxPlaywrightVersions.length ? camoufoxPlaywrightVersions : '(none, camoufox images will be skipped)',
);
console.error('Python runtime versions:', pythonRuntimeVersions);
console.error('Certificates updated at:', certificatesUpdatedAt || '(not available)');

Expand All @@ -51,6 +64,7 @@ const cacheParams: CacheValues = {
PYTHON_RUNTIME_VERSION: pythonRuntimeVersions,
PLAYWRIGHT_VERSION: latestFivePlaywrightVersions,
CAMOUFOX_VERSION: [latestCamoufoxVersion],
CAMOUFOX_PLAYWRIGHT_VERSION: camoufoxPlaywrightVersions,
CERTIFICATES_UPDATED_AT: certificatesUpdatedAt ? [certificatesUpdatedAt] : [],
};

Expand Down Expand Up @@ -92,20 +106,29 @@ for (const pythonVersion of supportedPythonVersions) {
return satisfies(`${pythonVersion}.0`, constraint);
})?.[1];

for (const playwrightVersion of latestFivePlaywrightVersions) {
if (maybePlaywrightVersionConstraint) {
if (!satisfies(playwrightVersion, maybePlaywrightVersionConstraint)) {
continue;
for (const imageName of imageNames) {
const isCamoufoxImage = imageName === 'python-playwright-camoufox';

// The camoufox image only supports a subset of the Playwright versions, so it gets its own version list, along
// with its own "latest" version - otherwise it would never receive the moving tags (e.g. `:3.14`, `:latest`).
const imagePlaywrightVersions = isCamoufoxImage ? camoufoxPlaywrightVersions : latestFivePlaywrightVersions;
const latestImagePlaywrightVersion = isCamoufoxImage
? latestCamoufoxPlaywrightVersion
: latestPlaywrightVersion;

for (const playwrightVersion of imagePlaywrightVersions) {
if (maybePlaywrightVersionConstraint) {
if (!satisfies(playwrightVersion, maybePlaywrightVersionConstraint)) {
continue;
}
}
}

for (const imageName of imageNames) {
matrix.include.push({
'image-name': imageName,
'python-version': pythonVersion,
'playwright-version': playwrightVersion,
'camoufox-version': latestCamoufoxVersion,
'is-latest': playwrightVersion === latestPlaywrightVersion ? 'true' : 'false',
'is-latest': playwrightVersion === latestImagePlaywrightVersion ? 'true' : 'false',
'latest-python-version': latestPythonVersion,
'supports-arm64': arm64UnsupportedImages.has(imageName) ? 'false' : 'true',
});
Expand Down
2 changes: 2 additions & 0 deletions .github/actions/version-matrix/src/shared/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const SELENIUM_VERSION_MARKER = 'SELENIUM_VERSION';
export const PLAYWRIGHT_VERSION_MARKER = 'PLAYWRIGHT_VERSION';
export const CRAWLEE_VERSION_MARKER = 'CRAWLEE_VERSION';
export const CAMOUFOX_VERSION_MARKER = 'CAMOUFOX_VERSION';
export const CAMOUFOX_PLAYWRIGHT_VERSION_MARKER = 'CAMOUFOX_PLAYWRIGHT_VERSION';
export const NODE_RUNTIME_VERSION_MARKER = 'NODE_RUNTIME_VERSION';
export const PYTHON_RUNTIME_VERSION_MARKER = 'PYTHON_RUNTIME_VERSION';
export const CERTIFICATES_UPDATED_AT_MARKER = 'CERTIFICATES_UPDATED_AT';
Expand Down Expand Up @@ -39,6 +40,7 @@ export type CacheValues = Partial<
| typeof PLAYWRIGHT_VERSION_MARKER
| typeof CRAWLEE_VERSION_MARKER
| typeof CAMOUFOX_VERSION_MARKER
| typeof CAMOUFOX_PLAYWRIGHT_VERSION_MARKER
| typeof CERTIFICATES_UPDATED_AT_MARKER,
string[]
>
Expand Down
56 changes: 56 additions & 0 deletions .github/actions/version-matrix/src/shared/camoufox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { satisfies } from 'semver';

import { resolveCamoufoxPlaywrightVersions } from './camoufox.ts';
import { fetchPackageManifest } from './npm.ts';

const playwrightVersions = ['1.56.0', '1.59.0', '1.60.0', '1.61.0', '1.62.0'];

describe('resolveCamoufoxPlaywrightVersions', () => {
it('reads the supported range from the camoufox package on PyPI', async () => {
// camoufox 0.5.4 declares `playwright<1.61`
const { range, versions } = await resolveCamoufoxPlaywrightVersions('python', '0.5.4', playwrightVersions);

assert.equal(range, '<1.61');
assert.deepEqual(versions, ['1.56.0', '1.59.0', '1.60.0']);
});

it('reads the supported range from the camoufox-js package on npm', async () => {
const { range, versions } = await resolveCamoufoxPlaywrightVersions('node', '0.12.0', playwrightVersions);

// camoufox-js declares the range as a `playwright-core` peer dependency. What it declares is up to camoufox and
// is expected to change, so this asserts that we read a range and apply it - not what the range happens to be.
assert.notEqual(range, undefined);
assert.deepEqual(
versions,
playwrightVersions.filter((version) => satisfies(version, range!)),
);
});

it('returns no versions when the range cannot be resolved', async () => {
const { range, versions } = await resolveCamoufoxPlaywrightVersions(
'python',
'0.0.0-does-not-exist',
playwrightVersions,
);

assert.equal(range, undefined);
assert.deepEqual(versions, []);
});
});

describe('fetchPackageManifest', () => {
it('reads the declared dependency ranges of a published version', async () => {
const manifest = await fetchPackageManifest('playwright', '1.60.0');

assert.equal(manifest.version, '1.60.0');
assert.equal(manifest.dependencies?.['playwright-core'], '1.60.0');
});

it('reads the declared peer dependency ranges of a published version', async () => {
const manifest = await fetchPackageManifest('camoufox-js', '0.12.0');

assert.equal(typeof manifest.peerDependencies?.['playwright-core'], 'string');
});
});
98 changes: 98 additions & 0 deletions .github/actions/version-matrix/src/shared/camoufox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { satisfies } from 'semver';
import { fetchPackageManifest } from './npm.ts';
import { fetchPackageVersion, findRequirementSpecifierSet, pythonSpecifierSetToSemverRange } from './pypi.ts';

/**
* Camoufox ships its own Firefox build, and its Juggler protocol only speaks to a limited range of Playwright
* versions - for example, camoufox 0.5.4 does not work with Playwright 1.61.0 and newer.
*
* That range is not pinned here. It is read from the camoufox package metadata instead, so that the images follow
* whatever camoufox currently supports:
*
* - `camoufox-js` declares it as a `playwright-core` peer dependency
* - `camoufox` (PyPI) declares it as a `playwright` requirement
*
* Note that the images install camoufox with a caret / compatible-release range, so the version actually installed
* may be newer than the one we read the metadata from. It is the newest version at the time the matrix is generated,
* which is as close as we can get without resolving the range twice.
*/
const camoufoxPackages = {
node: {
packageName: 'camoufox-js',
// `playwright` and `playwright-core` are released in lockstep under the same version numbers, so the range
// declared for `playwright-core` applies as-is to the `playwright` version installed in the image
dependencyName: 'playwright-core',
},
python: {
packageName: 'camoufox',
dependencyName: 'playwright',
},
} as const;

async function fetchNodeCamoufoxPlaywrightRange(camoufoxVersion: string) {
const { dependencyName } = camoufoxPackages.node;
const manifest = await fetchPackageManifest(camoufoxPackages.node.packageName, camoufoxVersion);

return manifest.peerDependencies?.[dependencyName] ?? manifest.dependencies?.[dependencyName];
}

async function fetchPythonCamoufoxPlaywrightRange(camoufoxVersion: string) {
const { dependencyName } = camoufoxPackages.python;
const { info } = await fetchPackageVersion(camoufoxPackages.python.packageName, camoufoxVersion);

const specifierSet = findRequirementSpecifierSet(info.requires_dist ?? [], dependencyName);

if (specifierSet === undefined) {
return undefined;
}

return pythonSpecifierSetToSemverRange(specifierSet);
}

/**
* Resolves which of the given Playwright versions can be used to build the camoufox image, based on the range the
* camoufox package declares for its Playwright dependency.
*
* If the range cannot be determined - the dependency is not declared, or it cannot be expressed as a semver range -
* this returns no versions at all, which leaves the camoufox image out of the matrix. Publishing an image that is
* known to be broken is worse than not publishing one, and the other images are unaffected either way.
*/
export async function resolveCamoufoxPlaywrightVersions(
runtime: 'node' | 'python',
camoufoxVersion: string,
playwrightVersions: string[],
) {
const { packageName } = camoufoxPackages[runtime];

let range: string | undefined;

try {
range =
runtime === 'node'
? await fetchNodeCamoufoxPlaywrightRange(camoufoxVersion)
: await fetchPythonCamoufoxPlaywrightRange(camoufoxVersion);
} catch (error) {
console.error(
`Failed to read the supported Playwright range from ${packageName}@${camoufoxVersion}, the camoufox image will be skipped`,
error,
);

return { range: undefined, versions: [] };
}

if (range === undefined) {
console.error(
`${packageName}@${camoufoxVersion} does not declare a Playwright dependency, the camoufox image will be skipped`,
);

return { range: undefined, versions: [] };
}

if (range === '*' || range === '') {
console.warn(
`${packageName}@${camoufoxVersion} declares no bounds on its Playwright dependency, the camoufox image will be built with the same versions as the other images`,
);
}

return { range, versions: playwrightVersions.filter((version) => satisfies(version, range)) };
}
22 changes: 22 additions & 0 deletions .github/actions/version-matrix/src/shared/npm.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { compare } from 'semver';

const npmPackageInfoRoute = (pkg: string) => `https://registry.npmjs.org/${pkg}`;
const npmPackageVersionInfoRoute = (pkg: string, version: string) => `https://registry.npmjs.org/${pkg}/${version}`;

interface PackageVersionInfo {
name: string;
version: string;
engines?: Record<string, string>;
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}

interface PackageInfo {
Expand All @@ -32,3 +35,22 @@ export async function fetchPackageVersions(packageName: string) {

return versions.sort((a, b) => compare(a, b));
}

/**
* Fetches the manifest (the `package.json`) of a single published version of a package.
*/
export async function fetchPackageManifest(packageName: string, version: string) {
const url = npmPackageVersionInfoRoute(packageName, version);

const response = await fetch(url);

if (!response.ok) {
throw new Error(`Failed to fetch package info for ${packageName}, version ${version}`, {
cause: await response.text(),
});
}

const json: PackageVersionInfo = await response.json();

return json;
}
Loading
Loading