Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7502de1
ref(dynamic-sampling): Migrate projectSampling to new form system (#1…
JonasBa Mar 30, 2026
67c4939
docs(dotnet): stable Metrics APIs (#110636)
Flash0ver Mar 30, 2026
1e6cf10
feat(profiling): Use spans data source for profiles search bar in EAP…
mjq Mar 30, 2026
9157c7b
fix(attributes): Validate user tag attributes exist in storage (#110745)
nsdeschenes Mar 30, 2026
3340aed
chore(ui): Add React DND component to app-frontend (#111780)
nsdeschenes Mar 30, 2026
bfb775e
chore(search): Add validation feature flag (#111752)
nsdeschenes Mar 30, 2026
d9cf05d
chore: add --changedSince base SHA to Jest in CI (#110568)
JoshuaKGoldberg Mar 30, 2026
5a5985a
fix(cells) Don't record proxied request failures towards circuit brea…
markstory Mar 30, 2026
956874e
fix(explore): Disable metric selector highlight scrolling (#111233)
nsdeschenes Mar 30, 2026
1d1708e
Refactor has_release_permission to protect mutations (#111021)
geoffg-sentry Mar 30, 2026
b5166a2
ref(preprod): Restructure commit_comparison conditional to if/else (#…
mtopo27 Mar 30, 2026
ae135a8
fix(replays): update stats link to new path (#111778)
priscilawebdev Mar 30, 2026
3a25e91
feat(oauth): Show public app device flow URLs (#111655)
dcramer Mar 30, 2026
6189ecf
chore(deps): bump orjson from 3.10.10 to 3.11.6 (#110973)
dependabot[bot] Mar 30, 2026
dcce8d5
feat(seer): Add trigger_explorer method to SeerOperator (#109675)
alexsohn1126 Mar 30, 2026
1975bff
meta(replay): Tweak language for replay batch deletion API docs (#111…
billyvg Mar 30, 2026
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
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get
/static/app/utils/theme/ @getsentry/design-engineering
/static/app/components/commandPalette/ @getsentry/design-engineering
/static/app/components/core/ @getsentry/design-engineering
/static/app/components/dnd/ @getsentry/design-engineering
/static/app/components/pageFilters/ @getsentry/design-engineering
/static/app/icons/ @getsentry/design-engineering
/static/app/stories/ @getsentry/design-engineering
Expand Down
1 change: 1 addition & 0 deletions .github/codeowners-coverage-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,7 @@ static/app/components/discover/transactionsList.spec.tsx
static/app/components/discover/transactionsList.tsx
static/app/components/discover/transactionsTable.tsx
static/app/components/discoverButton.tsx
static/app/components/dnd/dragReorderButton.tsx
static/app/components/dropdownButton.tsx
static/app/components/dropdownMenu/footer.tsx
static/app/components/dropdownMenu/index.spec.tsx
Expand Down
85 changes: 85 additions & 0 deletions .github/workflows/frontend-optional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ jobs:
testable_rules_changed: ${{ steps.changes.outputs.testable_rules_changed }}
typecheckable_rules_changed: ${{ steps.changes.outputs.typecheckable_rules_changed }}
frontend_all: ${{ steps.changes.outputs.frontend_all }}
merge_base: ${{ steps.merge_base.outputs.merge_base }}
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
fetch-depth: 100

- name: Check for frontend file changes
uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0
Expand All @@ -36,6 +39,88 @@ jobs:
filters: .github/file-filters.yml
list-files: shell

# On PRs, HEAD is the merge commit; its parents (HEAD^1, HEAD^2) are base and head.
# Merge base of those two is what Jest --changedSince needs.
# If merge base can't be computed or non-frontend files changed, output is empty
# and the optional Jest job will be skipped entirely.
- name: Get merge base for changedSince
id: merge_base
run: |
MERGE_BASE=$(git merge-base HEAD^1 HEAD^2 2>/dev/null) || true
if [ -n "$MERGE_BASE" ]; then
CHANGED=$(git diff --name-only "$MERGE_BASE" HEAD^2)
if echo "$CHANGED" | grep -qvE '^static/'; then
echo "Non-frontend file changed — skipping optional Jest"
MERGE_BASE=""
else
echo "Merge base: $MERGE_BASE (Jest will use --changedSince)"
fi
else
echo "Could not compute merge base — skipping optional Jest"
fi
echo "merge_base=${MERGE_BASE:-}" >> "$GITHUB_OUTPUT"

# This job intentionally mirrors `frontend-jest-tests` in frontend.yml.
# Our intent is to try it out for a few weeks and see if it's stable.
frontend-jest-tests-changed-only:
if: >-
needs.files-changed.outputs.merge_base != '' &&
(needs.files-changed.outputs.testable_rules_changed == 'true' || needs.files-changed.outputs.testable_modified == 'true')
needs: [files-changed]
name: Jest
# If you change the runs-on image, you must also change the runner in jest-balance.yml
# so that the balancer runs in the same environment as the tests.
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
# This helps not having to run multiple jobs because one fails, thus, reducing resource usage
# and reducing the risk that one of many runs would turn red again (read: intermittent tests)
fail-fast: false
matrix:
# XXX: When updating this, make sure you also update CI_NODE_TOTAL.
instance: [0, 1, 2, 3]

steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
name: Checkout sentry
with:
# PRs need history so we can compute merge base for Jest --changedSince.
# 100 is an arbitrary depth that will get most reasonable PRs' commits.
fetch-depth: ${{ github.event_name == 'pull_request' && '100' || '1' }}

- uses: ./.github/actions/setup-node-pnpm

- name: Download jest-balance.json
id: download-artifact
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
workflow: 38531594 # jest-balancer.yml
workflow_conclusion: success # The conclusion of the workflow we're looking for
branch: master # The branch we're looking for
name: jest-balance.json # Artifact name
name_is_regexp: false
path: tests/js/test-balancer/ # Directory where to extract artifact(s), defaults to the current directory
search_artifacts: true # Search for the last workflow run whose stored the artifact we're looking for
if_no_artifact_found: warn # Can be one of: "fail", "warn", "ignore"

- name: jest
env:
GITHUB_PR_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
GITHUB_PR_REF: ${{ github.event.pull_request.head.ref || github.ref }}
# XXX: CI_NODE_TOTAL must be hardcoded to the length of strategy.matrix.instance.
# Otherwise, if there are other things in the matrix, using strategy.job-total
# wouldn't be correct.
CI_NODE_TOTAL: 4
CI_NODE_INDEX: ${{ matrix.instance }}
# Disable testing-library from printing out any of of the DOM to
# stdout. No one actually looks through this in CI, they're just
# going to run it locally.
#
# This quiets up the logs quite a bit.
DEBUG_PRINT_LIMIT: 0
MERGE_BASE: ${{ needs.files-changed.outputs.merge_base }}
run: pnpm run test-ci --forceExit

typescript-native:
if: needs.files-changed.outputs.frontend_all == 'true'
needs: files-changed
Expand Down
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,7 @@ export default typescript.config([
name: 'files/jest related',
files: [
'tests/js/jest-pegjs-transform.js',
'tests/js/sentry-test/jest-environment.js',
'tests/js/sentry-test/mocks/*',
'tests/js/sentry-test/loadFixtures.ts',
'tests/js/setup.ts',
Expand Down
30 changes: 25 additions & 5 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,18 @@ let JEST_TESTS: string[] | undefined;
// to reexec itself here
if (CI && !process.env.JEST_LIST_TESTS_INNER) {
try {
const stdout = execFileSync('pnpm', ['exec', 'jest', '--listTests', '--json'], {
const listTestArguments = ['exec', 'jest', '--listTests', '--json'];

if (process.env.MERGE_BASE) {
console.log('MERGE_BASE detected:', process.env.MERGE_BASE);
listTestArguments.push(
'--changedSince',
process.env.MERGE_BASE,
'--passWithNoTests'
);
}

const stdout = execFileSync('pnpm', listTestArguments, {
stdio: 'pipe',
encoding: 'utf-8',
env: {...process.env, JEST_LIST_TESTS_INNER: '1'},
Expand Down Expand Up @@ -108,6 +119,10 @@ function getTestsForGroup(
allTests: ReadonlyArray<string>,
testStats: Record<string, number>
): string[] {
if (allTests.length === 0) {
return [];
}

const speculatedSuiteDuration = Object.values(testStats).reduce((a, b) => a + b, 0);
const targetDuration = speculatedSuiteDuration / nodeTotal;

Expand All @@ -122,8 +137,13 @@ function getTestsForGroup(
const tests = new Map<string, number>();
const SUITE_P50_DURATION_MS = 1500;

const allTestsSet = new Set(allTests);

// First, iterate over all of the tests we have stats for.
Object.entries(testStats).forEach(([test, duration]) => {
if (!allTestsSet.has(test)) {
return;
}
if (duration <= 0) {
throw new Error(`Test duration is <= 0 for ${test}`);
}
Expand Down Expand Up @@ -199,8 +219,8 @@ function getTestsForGroup(
}
}

if (!groups[nodeIndex]) {
throw new Error(`No tests found for node ${nodeIndex}`);
if (!groups[nodeIndex]?.length) {
return ['<rootDir>/__no_tests_for_this_shard__'];
}
return groups[nodeIndex].map(test => `<rootDir>/${test}`);
}
Expand Down Expand Up @@ -285,6 +305,7 @@ const config: Config.InitialOptions = {
// window/cookies state.
'@sentry/toolbar': '<rootDir>/tests/js/sentry-test/mocks/sentryToolbarMock.js',
},
passWithNoTests: !!process.env.MERGE_BASE,
setupFiles: [
'<rootDir>/static/app/utils/silence-react-unsafe-warnings.ts',
'jest-canvas-mock',
Expand Down Expand Up @@ -333,8 +354,7 @@ const config: Config.InitialOptions = {
*/
clearMocks: true,

// To disable the sentry jest integration, set this to 'jsdom'
testEnvironment: '@sentry/jest-environment/jsdom',
testEnvironment: '<rootDir>/tests/js/sentry-test/jest-environment.js',
testEnvironmentOptions: {
globalsCleanup: 'on',
sentryConfig: {
Expand Down
26 changes: 18 additions & 8 deletions src/sentry/api/bases/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,14 +706,21 @@ def has_release_permission(
organization: Organization | RpcOrganization,
release: Release | None = None,
project_ids: set[int] | None = None,
require_all_projects: bool = False,
) -> bool:
"""
Does the given request have permission to access this release, based
on the projects to which the release is attached?

If the given request has an actor (user or ApiKey), cache the results
for a minute on the unique combination of actor,org,release, and project
ids.
By default, access is granted if the user has access to *any* project
on the release (suitable for reads). When require_all_projects=True,
the user must have access to *all* projects on the release (use for
mutations). Without this, a user with access to one project on a
multi-project release could modify or delete it, affecting projects
they cannot access. The all-projects check respects Open Membership
via has_global_access.

Results are cached for 60s per actor/org/release/project-ids/mode.
"""
actor_id = None
has_perms = None
Expand All @@ -727,20 +734,23 @@ def has_release_permission(
if requested_project_ids is None:
requested_project_ids = self.get_requested_project_ids_unchecked(request)
key = "release_perms:1:%s" % hash_values(
[actor_id, organization.id, release.id if release is not None else 0]
[
actor_id,
organization.id,
release.id if release is not None else 0,
int(require_all_projects),
]
+ sorted(requested_project_ids)
)
has_perms = cache.get(key)
if has_perms is None:
projects = self.get_projects(request, organization, project_ids=project_ids)
# XXX(iambriccardo): The logic here is that you have access to this release if any of your projects
# associated with this release you have release permissions to. This is a bit of
# a problem because anyone can add projects to a release, so this check is easy
# to defeat.
if release is not None:
has_perms = ReleaseProject.objects.filter(
release=release, project__in=projects
).exists()
if has_perms and require_all_projects:
has_perms = request.access.has_projects_access(list(release.projects.all()))
else:
has_perms = len(projects) > 0

Expand Down
Loading
Loading