Compare commits

...

6 Commits

Author SHA1 Message Date
DarkSky b98ab495bb fix(server): race condition for sync 2026-04-03 02:00:02 +08:00
DarkSky 99b07c2ee1 fix: ios marketing version 2026-03-04 01:17:14 +08:00
DarkSky e1e0ac2345 chore: cleanup deps (#14525)
#### PR Dependency Tree


* **PR #14525** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
  * Removed an unused development dependency.
* Updated dotLottie/Lottie-related dependency versions across packages
and replaced a removed player dependency with the new package.

* **Refactor**
* AI animated icons now re-export from a shared component and are loaded
only in the browser, reducing upfront bundle weight and centralizing
icon assets.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-27 11:56:54 +08:00
DarkSky bdccf4e9fd fix: typo 2026-02-27 10:20:35 +08:00
DarkSky 11cf1928b5 fix(server): transaction error (#14518)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Events can be dispatched in a detached context to avoid inheriting the
current transaction.

* **Bug Fixes**
* Improved resilience and error handling for event processing (graceful
handling of deleted workspaces and ignorable DB errors).
  * More reliable owner assignment flow when changing document owners.

* **Tests**
  * Added tests for doc content staleness with deleted workspaces.
  * Added permission event tests for missing workspace/editor scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-26 19:53:22 +08:00
donqu1xotevincent 5215c73166 chore(ios): update description (#14522) 2026-02-26 19:49:50 +08:00
21 changed files with 583 additions and 2045 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
"@blocksuite/sync": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@lottiefiles/dotlottie-wc": "^0.9.4",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/hast": "^3.0.4",
+1 -1
View File
@@ -17,7 +17,7 @@
"@blocksuite/icons": "^2.2.17",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.3",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@lottiefiles/dotlottie-wc": "^0.9.4",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@vanilla-extract/css": "^1.17.0",
-1
View File
@@ -126,7 +126,6 @@
"@faker-js/faker": "^10.1.0",
"@nestjs/swagger": "^11.2.0",
"@nestjs/testing": "patch:@nestjs/testing@npm%3A10.4.15#~/.yarn/patches/@nestjs-testing-npm-10.4.15-d591a1705a.patch",
"@react-email/preview-server": "^4.3.2",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.1",
"@types/express-serve-static-core": "^5.0.6",
@@ -8,6 +8,7 @@ export class MockEventBus {
emit = this.stub.emitAsync;
emitAsync = this.stub.emitAsync;
emitDetached = this.stub.emitAsync;
broadcast = this.stub.broadcast;
last<Event extends EventName>(
@@ -88,12 +88,21 @@ export class EventBus
emit<T extends EventName>(event: T, payload: Events[T]) {
this.logger.debug(`Dispatch event: ${event}`);
// NOTE(@forehalo):
// Because all event handlers are wrapped in promisified metrics and cls context, they will always run in standalone tick.
// In which way, if handler throws, an unhandled rejection will be triggered and end up with process exiting.
// So we catch it here with `emitAsync`
this.emitter.emitAsync(event, payload).catch(e => {
this.emitter.emit('error', { event, payload, error: e });
this.dispatchAsync(event, payload);
return true;
}
/**
* Emit event in detached cls context to avoid inheriting current transaction.
*/
emitDetached<T extends EventName>(event: T, payload: Events[T]) {
this.logger.debug(`Dispatch event: ${event} (detached)`);
const requestId = this.cls.getId();
this.cls.run({ ifNested: 'override' }, () => {
this.cls.set(CLS_ID, requestId ?? genRequestId('event'));
this.dispatchAsync(event, payload);
});
return true;
@@ -166,6 +175,16 @@ export class EventBus
return this.emitter.waitFor(name, timeout);
}
private dispatchAsync<T extends EventName>(event: T, payload: Events[T]) {
// NOTE:
// Because all event handlers are wrapped in promisified metrics and cls context, they will always run in standalone tick.
// In which way, if handler throws, an unhandled rejection will be triggered and end up with process exiting.
// So we catch it here with `emitAsync`
this.emitter.emitAsync(event, payload).catch(e => {
this.emitter.emit('error', { event, payload, error: e });
});
}
private readonly bindEventHandlers = once(() => {
this.scanner.scan().forEach(({ event, handler, opts }) => {
this.on(event, handler, opts);
@@ -68,7 +68,7 @@ test('should update doc content to database when doc is updated', async t => {
const docId = randomUUID();
await adapter.pushDocUpdates(workspace.id, docId, updates);
await adapter.getDoc(workspace.id, docId);
await adapter.getDocBinNative(workspace.id, docId);
mock.method(docReader, 'parseDocContent', () => {
return {
@@ -181,3 +181,22 @@ test('should ignore update workspace content to database when parse workspace co
t.is(content!.name, null);
t.is(content!.avatarKey, null);
});
test('should ignore stale workspace when updating doc meta from snapshot event', async t => {
const { docReader, listener, models } = t.context;
const docId = randomUUID();
mock.method(docReader, 'parseDocContent', () => ({
title: 'test title',
summary: 'test summary',
}));
await models.workspace.delete(workspace.id);
await t.notThrowsAsync(async () => {
await listener.markDocContentCacheStale({
workspaceId: workspace.id,
docId,
blob: Buffer.from([0x01]),
});
});
});
@@ -110,7 +110,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
});
if (isNewDoc) {
this.event.emit('doc.created', {
this.event.emitDetached('doc.created', {
workspaceId,
docId,
editor: editorId,
@@ -334,7 +334,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
});
if (updatedSnapshot) {
this.event.emit('doc.snapshot.updated', {
this.event.emitDetached('doc.snapshot.updated', {
workspaceId: snapshot.spaceId,
docId: snapshot.docId,
blob,
+47 -12
View File
@@ -1,12 +1,29 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { OnEvent } from '../../base';
import { Models } from '../../models';
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
import { DocReader } from './reader';
const IGNORED_PRISMA_CODES = new Set(['P2003', 'P2025', 'P2028']);
function isIgnorableDocEventError(error: unknown) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return IGNORED_PRISMA_CODES.has(error.code);
}
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
return /transaction is aborted|transaction already closed/i.test(
error.message
);
}
return false;
}
@Injectable()
export class DocEventsListener {
private readonly logger = new Logger(DocEventsListener.name);
constructor(
private readonly docReader: DocReader,
private readonly models: Models,
@@ -20,21 +37,39 @@ export class DocEventsListener {
blob,
}: Events['doc.snapshot.updated']) {
await this.docReader.markDocContentCacheStale(workspaceId, docId);
const workspace = await this.models.workspace.get(workspaceId);
if (!workspace) {
this.logger.warn(
`Skip stale doc snapshot event for missing workspace ${workspaceId}/${docId}`
);
return;
}
const isDoc = workspaceId !== docId;
// update doc content to database
if (isDoc) {
const content = this.docReader.parseDocContent(blob);
if (!content) {
try {
if (isDoc) {
const content = this.docReader.parseDocContent(blob);
if (!content) {
return;
}
await this.models.doc.upsertMeta(workspaceId, docId, content);
} else {
// update workspace content to database
const content = this.docReader.parseWorkspaceContent(blob);
if (!content) {
return;
}
await this.models.workspace.update(workspaceId, content);
}
} catch (error) {
if (isIgnorableDocEventError(error)) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`Ignore stale doc snapshot event for ${workspaceId}/${docId}: ${message}`
);
return;
}
await this.models.doc.upsertMeta(workspaceId, docId, content);
} else {
// update workspace content to database
const content = this.docReader.parseWorkspaceContent(blob);
if (!content) {
return;
}
await this.models.workspace.update(workspaceId, content);
throw error;
}
}
@@ -0,0 +1,77 @@
import { randomUUID } from 'node:crypto';
import ava, { TestFn } from 'ava';
import {
createTestingModule,
type TestingModule,
} from '../../../__tests__/utils';
import { DocRole, Models, User, Workspace } from '../../../models';
import { EventsListener } from '../event';
import { PermissionModule } from '../index';
interface Context {
module: TestingModule;
models: Models;
listener: EventsListener;
}
const test = ava as TestFn<Context>;
let owner: User;
let workspace: Workspace;
test.before(async t => {
const module = await createTestingModule({ imports: [PermissionModule] });
t.context.module = module;
t.context.models = module.get(Models);
t.context.listener = module.get(EventsListener);
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
owner = await t.context.models.user.create({
email: `${randomUUID()}@affine.pro`,
});
workspace = await t.context.models.workspace.create(owner.id);
});
test.after.always(async t => {
await t.context.module.close();
});
test('should ignore default owner event when workspace does not exist', async t => {
await t.notThrowsAsync(async () => {
await t.context.listener.setDefaultPageOwner({
workspaceId: randomUUID(),
docId: randomUUID(),
editor: owner.id,
});
});
});
test('should ignore default owner event when editor does not exist', async t => {
await t.notThrowsAsync(async () => {
await t.context.listener.setDefaultPageOwner({
workspaceId: workspace.id,
docId: randomUUID(),
editor: randomUUID(),
});
});
});
test('should set owner when workspace and editor exist', async t => {
const docId = randomUUID();
await t.context.listener.setDefaultPageOwner({
workspaceId: workspace.id,
docId,
editor: owner.id,
});
const role = await t.context.models.docUser.get(
workspace.id,
docId,
owner.id
);
t.is(role?.type, DocRole.Owner);
});
@@ -1,10 +1,27 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { OnEvent } from '../../base';
import { Models } from '../../models';
const IGNORED_PRISMA_CODES = new Set(['P2003', 'P2025', 'P2028']);
function isIgnorablePermissionEventError(error: unknown) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return IGNORED_PRISMA_CODES.has(error.code);
}
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
return /transaction is aborted|transaction already closed/i.test(
error.message
);
}
return false;
}
@Injectable()
export class EventsListener {
private readonly logger = new Logger(EventsListener.name);
constructor(private readonly models: Models) {}
@OnEvent('doc.created')
@@ -15,6 +32,33 @@ export class EventsListener {
return;
}
await this.models.docUser.setOwner(workspaceId, docId, editor);
const workspace = await this.models.workspace.get(workspaceId);
if (!workspace) {
this.logger.warn(
`Skip default doc owner event for missing workspace ${workspaceId}/${docId}`
);
return;
}
const user = await this.models.user.get(editor);
if (!user) {
this.logger.warn(
`Skip default doc owner event for missing editor ${workspaceId}/${docId}/${editor}`
);
return;
}
try {
await this.models.docUser.setOwner(workspaceId, docId, editor);
} catch (error) {
if (isIgnorablePermissionEventError(error)) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`Ignore stale doc owner event for ${workspaceId}/${docId}/${editor}: ${message}`
);
return;
}
throw error;
}
}
}
+10 -27
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert';
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
import { WorkspaceDocUserRole } from '@prisma/client';
import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
@@ -14,31 +15,20 @@ export class DocUserModel extends BaseModel {
* Set or update the [Owner] of a doc.
* The old [Owner] will be changed to [Manager] if there is already an [Owner].
*/
@Transactional()
@Transactional<TransactionalAdapterPrisma>({ timeout: 15000 })
async setOwner(workspaceId: string, docId: string, userId: string) {
const oldOwner = await this.db.workspaceDocUserRole.findFirst({
await this.db.workspaceDocUserRole.updateMany({
where: {
workspaceId,
docId,
type: DocRole.Owner,
userId: { not: userId },
},
data: {
type: DocRole.Manager,
},
});
if (oldOwner) {
await this.db.workspaceDocUserRole.update({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId: oldOwner.userId,
},
},
data: {
type: DocRole.Manager,
},
});
}
await this.db.workspaceDocUserRole.upsert({
where: {
workspaceId_docId_userId: {
@@ -57,16 +47,9 @@ export class DocUserModel extends BaseModel {
type: DocRole.Owner,
},
});
if (oldOwner) {
this.logger.log(
`Transfer doc owner of [${workspaceId}/${docId}] from [${oldOwner.userId}] to [${userId}]`
);
} else {
this.logger.log(
`Set doc owner of [${workspaceId}/${docId}] to [${userId}]`
);
}
this.logger.log(
`Set doc owner of [${workspaceId}/${docId}] to [${userId}]`
);
}
/**
@@ -5,7 +5,11 @@ import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { CalendarProviderRequestError, CryptoHelper } from '../../../base';
import {
CalendarProviderRequestError,
CryptoHelper,
Mutex,
} from '../../../base';
import { ConfigModule } from '../../../base/config';
import { ServerConfigModule } from '../../../core/config';
import type {
@@ -14,6 +18,7 @@ import type {
} from '../../../models';
import { Models } from '../../../models';
import { CalendarModule } from '../index';
import { CalendarCronJobs } from '../cron';
import {
CalendarProvider,
CalendarProviderFactory,
@@ -85,8 +90,10 @@ const module = await createModule({
],
});
const calendarService = module.get(CalendarService);
const calendarCronJobs = module.get(CalendarCronJobs);
const providerFactory = module.get(CalendarProviderFactory);
const models = module.get(Models);
const mutex = module.get(Mutex);
module.get(CryptoHelper).onConfigInit();
const createAccount = async (
@@ -599,3 +606,57 @@ test('syncSubscription renews webhook channel when expiring', async t => {
t.is(updated?.customResourceId, 'new-resource');
t.truthy(updated?.channelExpiration);
});
test('pollAccounts skips syncing when cluster lock is unavailable', async t => {
mock.method(mutex, 'acquire', async () => undefined);
mock.method(
models.calendarSubscription,
'listAllWithAccountForSync',
async () => []
);
const syncAccountMock = mock.method(calendarService, 'syncAccount', async () => {
return;
});
await calendarCronJobs.pollAccounts();
t.is(syncAccountMock.mock.callCount(), 0);
});
test('pollAccounts only syncs due accounts', async t => {
mock.method(mutex, 'acquire', async () => ({
[Symbol.asyncDispose]: async () => {},
}));
mock.method(
models.calendarSubscription,
'listAllWithAccountForSync',
async () =>
[
{
accountId: 'due-account',
lastSyncAt: new Date(Date.now() - 31 * 60 * 1000),
account: {
refreshIntervalMinutes: 30,
},
},
{
accountId: 'fresh-account',
lastSyncAt: new Date(Date.now() - 5 * 60 * 1000),
account: {
refreshIntervalMinutes: 30,
},
},
] as any
);
const syncAccountMock = mock.method(calendarService, 'syncAccount', async () => {
return;
});
await calendarCronJobs.pollAccounts();
t.deepEqual(
syncAccountMock.mock.calls.map(call => call.arguments[0]),
['due-account']
);
});
@@ -1,18 +1,27 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { chunk } from 'lodash-es';
import { Mutex } from '../../base';
import { Models } from '../../models';
import { CalendarService } from './service';
const CALENDAR_POLL_LOCK_KEY = 'calendar:poll-accounts';
const CALENDAR_POLL_BATCH_SIZE = 10;
@Injectable()
export class CalendarCronJobs {
constructor(
private readonly models: Models,
private readonly calendar: CalendarService
private readonly calendar: CalendarService,
private readonly mutex: Mutex
) {}
@Cron(CronExpression.EVERY_MINUTE)
async pollAccounts() {
await using lock = await this.mutex.acquire(CALENDAR_POLL_LOCK_KEY);
if (!lock) return;
const subscriptions =
await this.models.calendarSubscription.listAllWithAccountForSync();
@@ -46,16 +55,18 @@ export class CalendarCronJobs {
}
const now = Date.now();
await Promise.allSettled(
Array.from(accountDueAt.entries()).map(([accountId, info]) => {
if (
const dueAccountIds = Array.from(accountDueAt.entries())
.filter(
([, info]) =>
!info.lastSyncAt ||
now - info.lastSyncAt.getTime() >= info.refreshInterval * 60 * 1000
) {
return this.calendar.syncAccount(accountId);
}
return Promise.resolve();
})
);
)
.map(([accountId]) => accountId);
for (const accountIds of chunk(dueAccountIds, CALENDAR_POLL_BATCH_SIZE)) {
await Promise.allSettled(
accountIds.map(accountId => this.calendar.syncAccount(accountId))
);
}
}
}
@@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { CalendarAccount, Prisma } from '@prisma/client';
import { addDays, subDays } from 'date-fns';
import { chunk } from 'lodash-es';
import {
CalendarProviderRequestError,
@@ -32,6 +33,7 @@ const SYNC_FAILURE_BACKOFF_KEY_PREFIX = 'calendar:sync:backoff:';
const SYNC_FAILURE_BACKOFF_BASE_MS = 5 * 60 * 1000;
const SYNC_FAILURE_BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
const SYNC_FAILURE_BACKOFF_TTL_SECONDS = 24 * 60 * 60;
const ACCOUNT_SYNC_BATCH_SIZE = 10;
@Injectable()
export class CalendarService {
@@ -433,11 +435,13 @@ export class CalendarService {
const subscriptions =
await this.models.calendarSubscription.listByAccountForSync(accountId);
await Promise.allSettled(
subscriptions.map(subscription =>
this.syncSubscription(subscription.id, { reason: 'polling' })
)
);
for (const batch of chunk(subscriptions, ACCOUNT_SYNC_BATCH_SIZE)) {
await Promise.allSettled(
batch.map(subscription =>
this.syncSubscription(subscription.id, { reason: 'polling' })
)
);
}
}
async listWorkspaceEvents(params: {
@@ -33,19 +33,37 @@ test('should not index workspace if indexer is disabled', async t => {
const count = module.queue.count('indexer.indexWorkspace');
// @ts-expect-error ignore missing fields
await indexerEvent.indexWorkspace({ id: 'test-workspace' });
await indexerEvent.indexWorkspace({
workspaceId: 'test-workspace',
docId: 'test-workspace',
});
t.is(module.queue.count('indexer.indexWorkspace'), count);
});
test('should index workspace if indexer is enabled', async t => {
test('should index workspace when root snapshot is updated', async t => {
// @ts-expect-error ignore missing fields
await indexerEvent.indexWorkspace({ id: 'test-workspace' });
await indexerEvent.indexWorkspace({
workspaceId: 'test-workspace',
docId: 'test-workspace',
});
const { payload } = await module.queue.waitFor('indexer.indexWorkspace');
t.is(payload.workspaceId, 'test-workspace');
});
test('should not index workspace when non-root snapshot is updated', async t => {
const count = module.queue.count('indexer.indexWorkspace');
// @ts-expect-error ignore missing fields
await indexerEvent.indexWorkspace({
workspaceId: 'test-workspace',
docId: 'child-doc',
});
t.is(module.queue.count('indexer.indexWorkspace'), count);
});
test('should not delete workspace if indexer is disabled', async t => {
Sinon.stub(config.indexer, 'enabled').value(false);
const count = module.queue.count('indexer.deleteWorkspace');
@@ -29,21 +29,20 @@ export class IndexerEvent {
);
}
@OnEvent('workspace.updated')
async indexWorkspace({ id }: Events['workspace.updated']) {
@OnEvent('doc.snapshot.updated')
async indexWorkspace({ workspaceId, docId }: Events['doc.snapshot.updated']) {
if (!this.config.indexer.enabled) {
return;
}
if (workspaceId !== docId) {
return;
}
await this.queue.add(
'indexer.indexWorkspace',
{
workspaceId: id,
},
{
jobId: `indexWorkspace/${id}`,
priority: 100,
}
{ workspaceId },
{ jobId: `indexWorkspace/${workspaceId}`, priority: 100 }
);
}
@@ -31,7 +31,7 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
private func showAIConsentAlert() {
let alert = UIAlertController(
title: "AI Feature Data Usage",
message: "To provide AI-powered features, your input (such as document content and conversation messages) will be sent to a third-party AI service for processing. This data is used solely to generate responses and is not used for any other purpose.\n\nBy continuing, you agree to share this data with the AI service.",
message: "To provide AI-powered features, your input (such as document content and conversation messages) will be sent to our third-party AI service providers (Google, Anthropic, or OpenAI, based on your choice) for processing. This data is used solely to generate responses and is not used for any other purpose.\n\nBy continuing, you agree to share this data with these AI services.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+1 -1
View File
@@ -26,13 +26,13 @@
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@dotlottie/player-component": "^2.7.12",
"@emotion/cache": "^11.14.0",
"@emotion/css": "^11.13.5",
"@emotion/react": "^11.14.0",
"@floating-ui/dom": "^1.6.13",
"@juggle/resize-observer": "^3.4.0",
"@lit/context": "^1.1.4",
"@lottiefiles/dotlottie-wc": "^0.9.4",
"@marsidev/react-turnstile": "^1.1.0",
"@myriaddreamin/typst-ts-renderer": "^0.7.0-rc2",
"@myriaddreamin/typst-ts-web-compiler": "^0.7.0-rc2",
File diff suppressed because one or more lines are too long
+52 -4
View File
@@ -73,8 +73,8 @@ update_app_stream_version() {
update_ios_marketing_version() {
local file_path=$1
# Remove everything after the "-"
local new_version=$(echo "$2" | sed -E 's/-.*$//')
# Normalize inputs like "v0.26.4-beta.1" to "0.26.4"
local new_version=$(echo "$2" | sed -E 's/^v//; s/-.*$//')
# Check if file exists
if [ ! -f "$file_path" ]; then
@@ -98,8 +98,56 @@ update_ios_marketing_version() {
rm "$file_path".bak
}
# Derive a date-based iOS MARKETING_VERSION from the latest stable/beta tag.
# Apple requires CFBundleShortVersionString to increase monotonically. Using
# date-based versions (YYYY.M.D) derived from the last stable/beta release tag
# ensures this. The user-facing App Store version is set separately in
# App Store Connect.
get_ios_version_from_git() {
# Find the most recent stable/beta tag reachable from HEAD (exclude canary/nightly)
local latest_tag
latest_tag=$(git describe --tags --match 'v[0-9]*' \
--exclude '*canary*' --exclude '*nightly*' \
--abbrev=0 HEAD 2>/dev/null)
if [ -z "$latest_tag" ]; then
# No stable/beta tag found, fall back to today's date
date +"%Y.%-m.%-d"
return
fi
# Get the tag creation date (tagger date for annotated tags, commit date for lightweight)
local tag_date
tag_date=$(git for-each-ref --format='%(creatordate:short)' "refs/tags/$latest_tag")
if [ -z "$tag_date" ]; then
date +"%Y.%-m.%-d"
return
fi
# Format as YYYY.M.D (no leading zeros for month/day)
local year month day
year=$(echo "$tag_date" | cut -d'-' -f1)
month=$((10#$(echo "$tag_date" | cut -d'-' -f2)))
day=$((10#$(echo "$tag_date" | cut -d'-' -f3)))
echo "${year}.${month}.${day}"
}
new_version=$1
ios_new_version=${IOS_APP_VERSION:-$new_version}
if [ -n "$IOS_APP_VERSION" ]; then
# Manual override via environment variable
ios_new_version=$IOS_APP_VERSION
elif echo "$new_version" | grep -qE '(canary|nightly)'; then
# Canary/nightly: use the date of the last stable/beta tag
ios_new_version=$(get_ios_version_from_git)
else
# Stable/beta release: use today's date
ios_new_version=$(date +"%Y.%-m.%-d")
fi
echo "iOS MARKETING_VERSION: $ios_new_version (app version: $new_version)"
update_app_version_in_helm_charts ".github/helm/affine/Chart.yaml" "$new_version"
update_app_version_in_helm_charts ".github/helm/affine/charts/graphql/Chart.yaml" "$new_version"
@@ -108,4 +156,4 @@ update_app_version_in_helm_charts ".github/helm/affine/charts/doc/Chart.yaml" "$
update_app_stream_version "packages/frontend/apps/electron/resources/affine.metainfo.xml" "$new_version"
update_ios_marketing_version "packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj" "$new_version"
update_ios_marketing_version "packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj" "$ios_new_version"
+152 -1928
View File
File diff suppressed because it is too large Load Diff