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:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
"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-strict": "node scripts/mobile/validate-app-store.mjs --strict",
"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";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(
projectRoot,
"ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png",
);
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
const iosIconDirectory = path.join(projectRoot, "ios/App/App/Assets.xcassets/AppIcon.appiconset");
const iosContentsPath = path.join(iosIconDirectory, "Contents.json");
const checkOnly = process.argv.includes("--check");
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`,
size: foregroundSize,
safeZone: true,
},
);
}
@@ -84,8 +84,19 @@ async function writeIfChanged(relativePath, content) {
return true;
}
async function renderPng(sourceImage, size) {
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC);
async function renderPng(sourceImage, size, safeZone = false) {
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);
}
@@ -93,14 +104,24 @@ async function main() {
const sourceBuffer = await fs.readFile(sourcePath);
const sourceImage = await Jimp.read(sourceBuffer);
if (sourceImage.bitmap.width !== 1024 || sourceImage.bitmap.height !== 1024) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 1024x1024 PNG.`);
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
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 = [];
for (const target of targets) {
const buffer = await renderPng(sourceImage, target.size);
for (const target of imageTargets) {
const buffer = await renderPng(sourceImage, target.size, target.safeZone);
if (await writeIfChanged(target.relativePath, buffer)) {
changed.push(target.relativePath);
}
@@ -113,12 +134,12 @@ async function main() {
}
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;
}
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) {
console.error(`- ${relativePath}`);
}
@@ -127,7 +148,7 @@ async function main() {
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) => {
+58 -14
View File
@@ -15,10 +15,12 @@ const workspaceRoot = fsSync.existsSync("/home/jeppe/pleno-workspace/frontend/pa
? "/home/jeppe/pleno-workspace"
: path.resolve(projectRoot, "..");
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 devPort = new URL(baseUrl).port || "5179";
const devHost = new URL(baseUrl).hostname || "127.0.0.1";
const generatedAt = new Date().toISOString();
const appStoreScreenshotsOnly = process.argv.includes("--app-store-screenshots");
const disallowedRoutePatterns = [
/^\/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 = [
{
key: "dashboard",
@@ -197,7 +222,7 @@ const screenshotScenes = [
timeout: 20_000,
});
if (device?.key === "phone") {
if (device?.key === "phone" || device?.key === "iphone-6.9") {
await page.waitForFunction(
() => {
const nextButton = document.querySelector('[data-testid="booking-mobile-next"]');
@@ -362,7 +387,7 @@ const products = [
const demoPosFixture = createPosFixture({
departments: [
{ 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,
departmentCategories: [
@@ -726,7 +751,10 @@ const demoSelfServe = {
};
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) {
@@ -857,7 +885,7 @@ async function installCommonPageState(context, page) {
async function setupMockApi(page, mode) {
const baseOptions = {
authenticated: true,
permissions: ["user"],
permissions: ["user", "add_own_department_selfserve_vehicle_conditions"],
sessionData: demoSession,
edgeGateways: false,
workerVersion: "playstore-graphics",
@@ -977,7 +1005,12 @@ async function captureScene(browser, device, scene) {
await page.evaluate(() => window.scrollTo(0, 0));
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({
path: outputPath,
type: "jpeg",
@@ -993,7 +1026,8 @@ async function captureScene(browser, device, scene) {
type: "screenshot",
scene: scene.key,
route: currentPath,
path: path.relative(outputRoot, outputPath),
path: path.relative(captureRoot, outputPath),
...(device.outputRoot ? { absolutePath: outputPath } : {}),
altText: scene.altText,
expected: device.expected,
};
@@ -1400,7 +1434,7 @@ async function createFeatureGraphicVariants(browser) {
}
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);
if (metadata.width !== entry.expected.width || metadata.height !== entry.expected.height) {
throw new Error(
@@ -1472,17 +1506,23 @@ async function run() {
const entries = [];
try {
entries.push(await copyAndValidateIcon());
for (const device of deviceProfiles) {
const captureDevices = appStoreScreenshotsOnly ? appStoreDeviceProfiles : deviceProfiles;
for (const device of captureDevices) {
for (const scene of screenshotScenes) {
entries.push(await captureScene(browser, device, scene));
}
}
entries.push(await createFeatureGraphic(browser));
entries.push(...(await createFeatureGraphicVariants(browser)));
await writeMetadata(entries);
if (appStoreScreenshotsOnly) {
for (const entry of entries) {
await validateAsset(entry);
}
} else {
entries.unshift(await copyAndValidateIcon());
entries.push(await createFeatureGraphic(browser));
entries.push(...(await createFeatureGraphicVariants(browser)));
await writeMetadata(entries);
}
} finally {
await browser.close();
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) => {
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 57 KiB