Closed krystofwoldrich closed 7 months ago
This would be amazing +1
Similar TS script based on @karbone4's comment made specifically for windows and working with expo-router (metro instead of webpack). Used with app.config.ts and includes--org and --project flags from extra prop
import { exec } from "child_process";
import util from "util";
// eslint-disable-next-line import/no-extraneous-dependencies
import { Command } from "commander";
import app from "./app.config";
import eas from "./eas.json";
// https://github.com/expo/sentry-expo/issues/319#issuecomment-1434954552
const promisifiedExec = util.promisify(exec);
const uploadAndroidSourceMap = async (updates: any) => {
const appVersion = app().version;
const androidVersionCode = app().android?.versionCode;
const androidPackageName = app().android?.package;
const androidUpdateId = updates.find(
(update: any) => update.platform === "android",
).id;
await promisifiedExec(
`cd ./dist/bundles/ && move android*.js index.android.bundle`,
);
const release =
await promisifiedExec(`cross-env ./node_modules/@sentry/cli/bin/sentry-cli \
releases \
--org ${app().extra.SENTRY_ORGANIZATION} \
--project ${app().extra.SENTRY_PROJECT} \
files ${androidPackageName}@${appVersion}+${androidVersionCode} \
upload-sourcemaps \
--dist ${androidUpdateId} \
--rewrite \
dist/bundles/index.android.bundle dist/bundles/android-*.map`);
if (release.stderr) {
console.error(release.stderr);
} else {
console.log(release.stdout);
}
};
const uploadIosSourceMap = async (updates: any) => {
const appVersion = app().version;
const iosBuildNumber = app().ios?.buildNumber;
const iosBundleID = app().ios?.bundleIdentifier;
const iosUpdateId = updates.find(
(update: any) => update.platform === "ios",
).id;
await promisifiedExec(`cd ./dist/bundles/ && move ios*.js main.jsbundle`);
const release =
await promisifiedExec(`cross-env ./node_modules/@sentry/cli/bin/sentry-cli \
releases \
--org ${app().extra.SENTRY_ORGANIZATION} \
--project ${app().extra.SENTRY_PROJECT} \
files ${iosBundleID}@${appVersion}+${iosBuildNumber} \
upload-sourcemaps \
--dist ${iosUpdateId} \
--rewrite \
dist/bundles/main.jsbundle dist/bundles/ios-*.map`);
if (release.stderr) {
console.error(release.stderr);
} else {
console.log(release.stdout);
}
};
const program = new Command()
.requiredOption("-p, --profile [value]", "EAS profile")
.action(async (options) => {
if (!Object.keys(eas.build).includes(options.profile)) {
console.error("Profile must be includes in : ", Object.keys(eas.build));
}
const easBuild = eas.build[options.profile as keyof typeof eas.build];
const { channel } = easBuild;
const channelUpdates = await promisifiedExec(
`eas update:list --branch ${channel} --non-interactive --json`,
);
if (channelUpdates.stderr) {
console.error(channelUpdates.stderr);
}
// With update of web using now Metro, channelUpdates' creates 2 updates
// Seemly web doesn't contain runtime and thus it is publishing
// multiple update groups with the following message:
// 👉 Since multiple runtime versions are defined, multiple update groups have been published.
// For this reason the first element with 'android, ios' platforms will be found and the group gotten from it
// TODO: As I understand sentry-expo doesn't work similarly for web with expo-router as Metro is used.
const groupID = JSON.parse(channelUpdates.stdout).currentPage.find(
(currentPage: any) => currentPage.platforms === "android, ios",
).group;
const updates = await promisifiedExec(`eas update:view ${groupID} --json`);
if (updates.stderr) {
console.error(updates.stderr);
}
await uploadAndroidSourceMap(JSON.parse(updates.stdout));
await uploadIosSourceMap(JSON.parse(updates.stdout));
});
program.parse(process.argv);
Run with: ts-node --esm sentryRelease.ts -p production
(since I needed to figure this out I will put it here to help others)
+1 This worked with expo publish but no longer with EAS update
This is no longer working properly when using hermes (which is becoming a standard).
If it can help someone, here is a config and a script that work for me:
Sentry.init({
...otherConfig,
// Release and dist must match those used to upload sourcemaps after EAS builds and updates
...(Platform.OS === 'web' || __DEV__ || Updates?.isEmbeddedLaunch === true
? {} // With embedded bundle, default dist (Application.nativeBuildVersion) is ok
: { dist: Updates.updateId }),
});
import { spawnSync } from 'child_process';
import readline from 'node:readline/promises'; // eslint-disable-line import/no-unresolved
import { readFileSync, copyFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
import { exec } from 'node:child_process';
import util from 'node:util';
import { z } from 'zod';
import { join } from 'node:path';
import axios from 'axios';
import { normalize } from 'path';
const APP_PATH = normalize('apps/myapp');
const APP_BUNDLES_SUBPATH = normalize('dist/bundles');
const skipEas = process.argv.includes('--skip-eas');
const promisifiedExec = util.promisify(exec);
const uploadSourceMap = ({
bundleFileRegex,
sentryBundleFileName,
release,
dist,
}: { bundleFileRegex: RegExp; sentryBundleFileName: string; release: string; dist: string }) => {
const bundleFiles = readdirSync(join(APP_PATH, APP_BUNDLES_SUBPATH)).filter(
(file) => bundleFileRegex.exec(file) !== null,
);
if (bundleFiles.length > 1) throw new Error('Multiple bundle files found');
const bundleFile = bundleFiles[0];
if (bundleFile === undefined) throw new Error('No bundle file found');
const sourcemapFileName = bundleFile.replace(/.js$/u, '.map');
copyFileSync(
join(APP_PATH, APP_BUNDLES_SUBPATH, bundleFile),
join(APP_PATH, APP_BUNDLES_SUBPATH, sentryBundleFileName),
);
try {
// Remove absolute path prefix
const sourceMap = z
.object({ sources: z.array(z.string()) })
.passthrough()
.parse(
JSON.parse(readFileSync(join(APP_PATH, APP_BUNDLES_SUBPATH, sourcemapFileName), 'utf8')),
);
let longestCommonPrefix = sourceMap.sources[0];
for (const filePath of sourceMap.sources) {
if (filePath.startsWith(longestCommonPrefix)) continue;
while (!filePath.startsWith(longestCommonPrefix)) {
longestCommonPrefix = longestCommonPrefix.slice(0, -1);
}
}
for (let i = 0; i < sourceMap.sources.length; i++) {
sourceMap.sources[i] = sourceMap.sources[i]
.slice(longestCommonPrefix.length)
.replaceAll('\\', '/');
}
writeFileSync(
join(APP_PATH, APP_BUNDLES_SUBPATH, sourcemapFileName),
JSON.stringify(sourceMap),
'utf8',
);
const res = spawnSync(
[
'yarn sentry-cli releases --org stairwage --project sentry_project',
`files ${release} upload-sourcemaps --no-dedupe --dist ${dist}`,
join(APP_BUNDLES_SUBPATH, sentryBundleFileName),
join(APP_BUNDLES_SUBPATH, sourcemapFileName),
].join(' '),
{
shell: true,
stdio: 'inherit',
cwd: APP_PATH,
},
);
if (res.status !== 0) throw new Error('Sentry upload failed');
} finally {
unlinkSync(join(APP_PATH, APP_BUNDLES_SUBPATH, sentryBundleFileName));
}
};
const askFor = async (what: string) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const res = await rl.question(what + '\n');
rl.close();
return res;
};
const main = async () => {
const bundleRes = skipEas
? 'n'
: await askFor('Bundle and publish (or publish already bundlefiles in /dist)? y/n');
if (!['y', 'n'].includes(bundleRes)) throw new Error('Invalid choice');
const bundle = bundleRes === 'y';
const appVersion = '0.1.2';
const appVersionComment = 'New feature XXX';
const runtimeVersion = '12';
const buildNumber = '27';
const nativeAppVersion = '0.1.0';
const requiredNativeAppVersionStart = appVersion.split('.').slice(0, 2).join('.') + '.';
if (
!nativeAppVersion.startsWith(requiredNativeAppVersionStart) ||
parseInt(nativeAppVersion.split('.')[2], 10) >= parseInt(appVersion.split('.')[2], 10)
) {
throw new Error('Invalid native native release version');
}
// To compute bundle id for Sentry release and choose EAS update branche
const stage = await askFor('Stage (dev, staging, prod):');
if (!['prod', 'staging', 'dev'].includes(stage)) {
throw new Error('Invalid stage');
}
const platform = await askFor('Platform (ios, android, all):');
if (!['ios', 'android', 'all'].includes(platform)) {
throw new Error('Invalid platform');
}
if (skipEas) {
console.log('Skipping EAS update');
} else {
if (bundle) {
console.log('Bundling and publishing EAS update to ' + stage);
const res = spawnSync(
`eas update -p ${platform} --branch ${stage} --message "${appVersionComment}"`,
{
shell: true,
stdio: 'inherit',
cwd: APP_PATH,
env: {
...process.env,
APP_VARIANT: stage,
},
},
);
if (res.status !== 0) {
throw new Error('EAS update failed');
}
} else {
console.log('Publishing already bundled EAS update to ' + stage);
const res = spawnSync(
`eas update -p ${platform} --branch ${stage} --message "${appVersionComment}" --skip-bundler`,
{
shell: true,
stdio: 'inherit',
cwd: APP_PATH,
env: {
...process.env,
APP_VARIANT: stage,
},
},
);
if (res.status !== 0) {
throw new Error('EAS update failed');
}
}
}
const branchLastUpdateGroupsRes = await promisifiedExec(
`eas update:list --branch ${stage} --json --non-interactive`,
{
cwd: APP_PATH,
env: {
...process.env,
APP_VARIANT: stage,
},
},
);
if (branchLastUpdateGroupsRes.stderr !== '') {
console.error(`EAS updates listing error: ${branchLastUpdateGroupsRes.stderr}`);
}
let lastUpdateGroup = undefined;
try {
const branchLastUpdateGroups = z
.object({
name: z.string(),
id: z.string(),
currentPage: z.array(
z.object({
branch: z.string(),
message: z.string(),
runtimeVersion: z.string(),
isRollBackToEmbedded: z.boolean(),
group: z.string(),
platforms: z.string(),
}),
),
})
.parse(
JSON.parse(
/^(?:\n|[^{])*(\{(?:.|\n)*\})(?:\n|[^}])*$/u.exec(
branchLastUpdateGroupsRes.stdout,
)?.[1] ?? '',
),
);
if (branchLastUpdateGroups.name !== stage) throw new Error('Invalid branch name');
lastUpdateGroup = branchLastUpdateGroups.currentPage[0];
if (lastUpdateGroup === undefined) throw new Error('No update found');
if (lastUpdateGroup.branch !== stage) throw new Error('Invalid branch name');
if (!lastUpdateGroup.message.startsWith(`"${appVersionComment}"`)) {
throw new Error('Invalid update message');
}
if (lastUpdateGroup.runtimeVersion !== runtimeVersion) {
throw new Error('Invalid runtime version');
}
if (lastUpdateGroup.isRollBackToEmbedded) throw new Error('Invalid update type (roll back)');
if (lastUpdateGroup.platforms !== (platform === 'all' ? 'android, ios' : platform)) {
throw new Error('Invalid update platforms');
}
} catch (err) {
console.log(branchLastUpdateGroupsRes.stdout);
throw err;
}
const updates = await promisifiedExec(`eas update:view ${lastUpdateGroup.group} --json`);
if (updates.stderr.length > 0) console.error(updates.stderr);
const groupUpdatesRes = await promisifiedExec(`eas update:view ${lastUpdateGroup.group} --json`, {
cwd: APP_PATH,
env: {
...process.env,
APP_VARIANT: stage,
},
});
if (groupUpdatesRes.stderr !== '') {
console.error(`EAS group view error: ${groupUpdatesRes.stderr}`);
}
let groupUpdates = undefined;
try {
groupUpdates = z
.array(
z.object({
id: z.string(),
createdAt: z.string(),
group: z.string(),
branch: z.string(),
message: z.string(),
runtimeVersion: z.string(),
platform: z.enum(['android', 'ios']),
manifestPermalink: z.string(),
isRollBackToEmbedded: z.boolean(),
gitCommitHash: z.string(),
}),
)
.parse(
JSON.parse(
/^(?:\n|[^[])*(\[(?:.|\n)*\])(?:\n|[^\]])*$/u.exec(groupUpdatesRes.stdout)?.[1] ?? '',
),
);
for (const update of groupUpdates) {
if (!skipEas && new Date(update.createdAt).valueOf() < Date.now() - 5 * 60 * 1000) {
throw new Error('Update too old');
}
if (update.group !== lastUpdateGroup.group) throw new Error('Invalid group');
if (update.branch !== stage) throw new Error('Invalid branch name');
if (update.message.startsWith(`"${appVersionComment}"`)) {
throw new Error('Invalid update message');
}
if (update.runtimeVersion !== runtimeVersion) throw new Error('Invalid runtime version');
if (update.isRollBackToEmbedded) throw new Error('Invalid update type (roll back)');
}
} catch (err) {
console.log(groupUpdatesRes.stdout);
throw err;
}
// Upload to Sentry
if (platform === 'android' || platform === 'all') {
const update = groupUpdates.find((u) => u.platform === 'android');
if (update === undefined) throw new Error('No android update found');
const manifest = (await axios.get<string>(update.manifestPermalink)).data;
const launchAssetKey = /"launchAsset":\{"hash":"[^"]+","key":"([^"]+)"/u.exec(manifest)?.[1];
if (launchAssetKey === undefined) {
console.log(manifest);
throw new Error('Wrong manifest format');
}
console.log('Uploading android source map to Sentry');
uploadSourceMap({
bundleFileRegex: /^android-.*\.js$/u,
sentryBundleFileName: 'index.android.bundle',
release: `${`com.myorg.myapp${
stage === 'prod' ? '' : `_${stage}`
}`}@${nativeAppVersion}+${buildNumber}`,
dist: update.id,
});
}
if (platform === 'ios' || platform === 'all') {
const update = groupUpdates.find((u) => u.platform === 'ios');
if (update === undefined) throw new Error('No ios update found');
const manifest = (await axios.get<string>(update.manifestPermalink)).data;
const launchAssetKey = /"launchAsset":\{"hash":"[^"]+","key":"([^"]+)"/u.exec(manifest)?.[1];
if (launchAssetKey === undefined) {
console.log(manifest);
throw new Error('Wrong manifest format');
}
console.log('Uploading ios source map to Sentry');
uploadSourceMap({
bundleFileRegex: /^ios-.*\.js$/u,
sentryBundleFileName: launchAssetKey + '.bundle',
release: `${`com.myorg.myapp${
stage === 'prod' ? '' : `-${stage}`
}`}@${nativeAppVersion}+${buildNumber}`,
dist: update.id,
});
}
};
void main();
This issue is stale because it has been open for 60 days with no activity. If there is no activity in the next 7 days, the issue will be closed.
+1
+1 after migrating to eas update and sdk49 (bare) we've lost our stacktraces in sentry. having some reliable resources to simplify this would be incredibly appreciated!
EDIT: this script saved us -- https://gist.github.com/nandorojo/8371475fe9912cb6b8d4f326664f1fc6
@wen-kai This script did not work for us, the upload succeeds, but still scrambled source maps, we're using Expo v49.
Is this script still functional for you guys?
Doesn't work for me either!
I'd love an auto-upload script as well. It's sorely lacking
FI I had to change Android bundle file name to "index.android.bundle" to fix my script above (I've edited the script's comment).
Hello everyone,
@sentry/react-native
now supports Expo out of the box!
You can upload source maps for EAS Update as easily as npx sentry-expo-upload-sourcemaps dist
.
Update to https://github.com/getsentry/sentry-react-native/releases/tag/5.16.0 or newer to get all the new features.
Migration guides available:
More config here:
SENTRY_PROJECT=project-slug \
SENTRY_ORG=org-slug \
SENTRY_AUTH_TOKEN=super-secret-token \
npx sentry-expo-upload-sourcemaps dist
At the moment
sentry-expo
uploads source maps to Sentry only during native app builds.sentry-expo
could ship with a script similar to the user solutions or if possible hook at the end ofeas update
.User-created solutions: