Complete App Store artwork and signed release automation (#200)

Align generated artwork with the published Truck Wash storefront, add strict iPhone and iPad App Store screenshots, and complete signed iOS release automation.
This commit is contained in:
Jeppe B
2026-07-20 18:19:47 +00:00
committed by GitHub
parent ed2d67934c
commit 97df3193e3
52 changed files with 93 additions and 27 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 B

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -74,6 +74,7 @@
"mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs", "mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check", "mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
"mobile:ios:device": "node scripts/mobile/ios-device.mjs", "mobile:ios:device": "node scripts/mobile/ios-device.mjs",
"mobile:ios:screenshots": "node scripts/playstore/generate-graphics.mjs --app-store-screenshots",
"mobile:ios:storefront:check": "node scripts/mobile/validate-app-store.mjs", "mobile:ios:storefront:check": "node scripts/mobile/validate-app-store.mjs",
"mobile:ios:storefront:check-strict": "node scripts/mobile/validate-app-store.mjs --strict", "mobile:ios:storefront:check-strict": "node scripts/mobile/validate-app-store.mjs --strict",
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs" "playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 57 KiB

+34 -13
View File
@@ -5,10 +5,9 @@ import { fileURLToPath } from "node:url";
import Jimp from "jimp"; import Jimp from "jimp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join( const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
projectRoot, const iosIconDirectory = path.join(projectRoot, "ios/App/App/Assets.xcassets/AppIcon.appiconset");
"ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png", const iosContentsPath = path.join(iosIconDirectory, "Contents.json");
);
const checkOnly = process.argv.includes("--check"); const checkOnly = process.argv.includes("--check");
const launcherBackground = "#FFFFFF"; const launcherBackground = "#FFFFFF";
@@ -41,6 +40,7 @@ for (const [density, scale] of Object.entries(densityScale)) {
{ {
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`, relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`,
size: foregroundSize, size: foregroundSize,
safeZone: true,
}, },
); );
} }
@@ -84,8 +84,19 @@ async function writeIfChanged(relativePath, content) {
return true; return true;
} }
async function renderPng(sourceImage, size) { async function renderPng(sourceImage, size, safeZone = false) {
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC); const image = new Jimp(size, size, launcherBackground);
const artworkSize = safeZone ? Math.round(size * 0.72) : size;
const artwork = sourceImage.clone();
if (artwork.bitmap.width !== artworkSize || artwork.bitmap.height !== artworkSize) {
artwork.resize(artworkSize, artworkSize, Jimp.RESIZE_BICUBIC);
}
const offset = Math.round((size - artworkSize) / 2);
image.composite(artwork, offset, offset);
image.scan(0, 0, size, size, (_x, _y, index) => {
image.bitmap.data[index + 3] = 255;
});
image.colorType(2);
return image.getBufferAsync(Jimp.MIME_PNG); return image.getBufferAsync(Jimp.MIME_PNG);
} }
@@ -93,14 +104,24 @@ async function main() {
const sourceBuffer = await fs.readFile(sourcePath); const sourceBuffer = await fs.readFile(sourcePath);
const sourceImage = await Jimp.read(sourceBuffer); const sourceImage = await Jimp.read(sourceBuffer);
if (sourceImage.bitmap.width !== 1024 || sourceImage.bitmap.height !== 1024) { if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 1024x1024 PNG.`); throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 512x512 PNG.`);
} }
const iosContents = JSON.parse(await fs.readFile(iosContentsPath, "utf8"));
const iosTargets = iosContents.images.map(({ filename, scale, size }) => ({
relativePath: path.relative(projectRoot, path.join(iosIconDirectory, filename)),
size: Math.round(Number.parseFloat(size) * Number.parseFloat(scale)),
}));
iosTargets.push({
relativePath: path.relative(projectRoot, path.join(iosIconDirectory, "AppIcon-512@2x.png")),
size: 1024,
});
const imageTargets = [...targets, ...iosTargets];
const changed = []; const changed = [];
for (const target of targets) { for (const target of imageTargets) {
const buffer = await renderPng(sourceImage, target.size); const buffer = await renderPng(sourceImage, target.size, target.safeZone);
if (await writeIfChanged(target.relativePath, buffer)) { if (await writeIfChanged(target.relativePath, buffer)) {
changed.push(target.relativePath); changed.push(target.relativePath);
} }
@@ -113,12 +134,12 @@ async function main() {
} }
if (changed.length === 0) { if (changed.length === 0) {
console.log(`Android icon assets are current (${targets.length + xmlTargets.length} files).`); console.log(`Mobile icon assets are current (${imageTargets.length + xmlTargets.length} files).`);
return; return;
} }
if (checkOnly) { if (checkOnly) {
console.error("Android icon assets are out of date:"); console.error("Mobile icon assets are out of date:");
for (const relativePath of changed) { for (const relativePath of changed) {
console.error(`- ${relativePath}`); console.error(`- ${relativePath}`);
} }
@@ -127,7 +148,7 @@ async function main() {
return; return;
} }
console.log(`Updated ${changed.length} Android icon asset${changed.length === 1 ? "" : "s"}.`); console.log(`Updated ${changed.length} mobile icon asset${changed.length === 1 ? "" : "s"}.`);
} }
main().catch((error) => { main().catch((error) => {
+58 -14
View File
@@ -15,10 +15,12 @@ const workspaceRoot = fsSync.existsSync("/home/jeppe/pleno-workspace/frontend/pa
? "/home/jeppe/pleno-workspace" ? "/home/jeppe/pleno-workspace"
: path.resolve(projectRoot, ".."); : path.resolve(projectRoot, "..");
const outputRoot = path.join(workspaceRoot, "playstoregraphics"); const outputRoot = path.join(workspaceRoot, "playstoregraphics");
const appStoreScreenshotRoot = path.join(projectRoot, "fastlane/screenshots/da-DK");
const baseUrl = process.env.PLAYSTORE_GRAPHICS_BASE_URL || "http://127.0.0.1:5179"; const baseUrl = process.env.PLAYSTORE_GRAPHICS_BASE_URL || "http://127.0.0.1:5179";
const devPort = new URL(baseUrl).port || "5179"; const devPort = new URL(baseUrl).port || "5179";
const devHost = new URL(baseUrl).hostname || "127.0.0.1"; const devHost = new URL(baseUrl).hostname || "127.0.0.1";
const generatedAt = new Date().toISOString(); const generatedAt = new Date().toISOString();
const appStoreScreenshotsOnly = process.argv.includes("--app-store-screenshots");
const disallowedRoutePatterns = [ const disallowedRoutePatterns = [
/^\/admin(?:\/|$)/i, /^\/admin(?:\/|$)/i,
@@ -81,6 +83,29 @@ const deviceProfiles = [
}, },
]; ];
const appStoreDeviceProfiles = [
{
key: "iphone-6.9",
outputRoot: appStoreScreenshotRoot,
fileNamePrefix: "iphone-6.9-",
viewport: { width: 440, height: 956 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
expected: { width: 1320, height: 2868 },
},
{
key: "ipad-13",
outputRoot: appStoreScreenshotRoot,
fileNamePrefix: "ipad-13-",
viewport: { width: 768, height: 1024 },
deviceScaleFactor: 2.6875,
isMobile: true,
hasTouch: true,
expected: { width: 2064, height: 2752 },
},
];
const screenshotScenes = [ const screenshotScenes = [
{ {
key: "dashboard", key: "dashboard",
@@ -197,7 +222,7 @@ const screenshotScenes = [
timeout: 20_000, timeout: 20_000,
}); });
if (device?.key === "phone") { if (device?.key === "phone" || device?.key === "iphone-6.9") {
await page.waitForFunction( await page.waitForFunction(
() => { () => {
const nextButton = document.querySelector('[data-testid="booking-mobile-next"]'); const nextButton = document.querySelector('[data-testid="booking-mobile-next"]');
@@ -362,7 +387,7 @@ const products = [
const demoPosFixture = createPosFixture({ const demoPosFixture = createPosFixture({
departments: [ departments: [
{ id: 1, name: "Roskilde", exclude_from_invoicing: false }, { id: 1, name: "Roskilde", exclude_from_invoicing: false },
{ id: 12, name: "Taastrup", exclude_from_invoicing: false }, { id: 12, name: "Taastrup", exclude_from_invoicing: false, bookingsystem_time_based_enabled: true },
], ],
products, products,
departmentCategories: [ departmentCategories: [
@@ -726,7 +751,10 @@ const demoSelfServe = {
}; };
async function ensureDirectories() { async function ensureDirectories() {
await Promise.all(requiredDirectories.map((directory) => fs.mkdir(path.join(outputRoot, directory), { recursive: true }))); await Promise.all([
...requiredDirectories.map((directory) => fs.mkdir(path.join(outputRoot, directory), { recursive: true })),
fs.mkdir(appStoreScreenshotRoot, { recursive: true }),
]);
} }
function isDisallowedRoute(routePath) { function isDisallowedRoute(routePath) {
@@ -857,7 +885,7 @@ async function installCommonPageState(context, page) {
async function setupMockApi(page, mode) { async function setupMockApi(page, mode) {
const baseOptions = { const baseOptions = {
authenticated: true, authenticated: true,
permissions: ["user"], permissions: ["user", "add_own_department_selfserve_vehicle_conditions"],
sessionData: demoSession, sessionData: demoSession,
edgeGateways: false, edgeGateways: false,
workerVersion: "playstore-graphics", workerVersion: "playstore-graphics",
@@ -977,7 +1005,12 @@ async function captureScene(browser, device, scene) {
await page.evaluate(() => window.scrollTo(0, 0)); await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(350); await page.waitForTimeout(350);
const outputPath = path.join(outputRoot, device.screenshotDir, scene.fileName); const captureRoot = device.outputRoot || outputRoot;
const outputPath = path.join(
captureRoot,
device.screenshotDir || "",
`${device.fileNamePrefix || ""}${scene.fileName}`,
);
await page.screenshot({ await page.screenshot({
path: outputPath, path: outputPath,
type: "jpeg", type: "jpeg",
@@ -993,7 +1026,8 @@ async function captureScene(browser, device, scene) {
type: "screenshot", type: "screenshot",
scene: scene.key, scene: scene.key,
route: currentPath, route: currentPath,
path: path.relative(outputRoot, outputPath), path: path.relative(captureRoot, outputPath),
...(device.outputRoot ? { absolutePath: outputPath } : {}),
altText: scene.altText, altText: scene.altText,
expected: device.expected, expected: device.expected,
}; };
@@ -1400,7 +1434,7 @@ async function createFeatureGraphicVariants(browser) {
} }
async function validateAsset(entry) { async function validateAsset(entry) {
const absolutePath = path.join(outputRoot, entry.path); const absolutePath = entry.absolutePath || path.join(outputRoot, entry.path);
const metadata = await readImageMetadata(absolutePath); const metadata = await readImageMetadata(absolutePath);
if (metadata.width !== entry.expected.width || metadata.height !== entry.expected.height) { if (metadata.width !== entry.expected.width || metadata.height !== entry.expected.height) {
throw new Error( throw new Error(
@@ -1472,17 +1506,23 @@ async function run() {
const entries = []; const entries = [];
try { try {
entries.push(await copyAndValidateIcon()); const captureDevices = appStoreScreenshotsOnly ? appStoreDeviceProfiles : deviceProfiles;
for (const device of captureDevices) {
for (const device of deviceProfiles) {
for (const scene of screenshotScenes) { for (const scene of screenshotScenes) {
entries.push(await captureScene(browser, device, scene)); entries.push(await captureScene(browser, device, scene));
} }
} }
entries.push(await createFeatureGraphic(browser)); if (appStoreScreenshotsOnly) {
entries.push(...(await createFeatureGraphicVariants(browser))); for (const entry of entries) {
await writeMetadata(entries); await validateAsset(entry);
}
} else {
entries.unshift(await copyAndValidateIcon());
entries.push(await createFeatureGraphic(browser));
entries.push(...(await createFeatureGraphicVariants(browser)));
await writeMetadata(entries);
}
} finally { } finally {
await browser.close(); await browser.close();
if (server) { if (server) {
@@ -1494,7 +1534,11 @@ async function run() {
} }
} }
console.log(`Generated ${entries.length} Play Store graphics in ${outputRoot}`); if (appStoreScreenshotsOnly) {
console.log(`Generated ${entries.length} App Store screenshots in ${appStoreScreenshotRoot}`);
} else {
console.log(`Generated ${entries.length} Play Store graphics in ${outputRoot}`);
}
} }
run().catch((error) => { run().catch((error) => {
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 57 KiB