From 6fc0faff124fde13ff6177c6e9fab20f8a4e4c62 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 15 Jul 2026 13:02:05 +0900 Subject: [PATCH 1/3] feat: classify mobile releases for store publishing --- .../classify-mobile-release/action.yml | 29 ++ .../classify-mobile-release.mjs | 204 ++++++++++++++ .../classify-mobile-release.spec.mjs | 264 ++++++++++++++++++ .github/actions/vitest.config.mjs | 1 + package.json | 2 +- projects/kit/README.md | 120 ++++---- 6 files changed, 555 insertions(+), 65 deletions(-) create mode 100644 .github/actions/classify-mobile-release/action.yml create mode 100644 .github/actions/classify-mobile-release/classify-mobile-release.mjs create mode 100644 .github/actions/classify-mobile-release/classify-mobile-release.spec.mjs diff --git a/.github/actions/classify-mobile-release/action.yml b/.github/actions/classify-mobile-release/action.yml new file mode 100644 index 0000000..09170c9 --- /dev/null +++ b/.github/actions/classify-mobile-release/action.yml @@ -0,0 +1,29 @@ +name: Classify mobile release +description: Route a tagged Capacitor release to Live Update or native store publishing. +inputs: + app-path: + description: Path to the Capacitor app from the repository root. + required: false + default: app + tag: + description: Release tag in vX.Y.Z or vX.Y.Z-N format. + required: true +outputs: + release_kind: + description: Either live-update or store. + value: ${{ steps.classify.outputs.release_kind }} + version: + description: Normalized release version without the v prefix. + value: ${{ steps.classify.outputs.version }} + build_number: + description: Shared Android and iOS native build number. + value: ${{ steps.classify.outputs.build_number }} + production_channel: + description: Versioned production channel for the native build. + value: ${{ steps.classify.outputs.production_channel }} +runs: + using: composite + steps: + - id: classify + shell: bash + run: node "$GITHUB_ACTION_PATH/classify-mobile-release.mjs" --app-path "${{ inputs.app-path }}" --tag "${{ inputs.tag }}" diff --git a/.github/actions/classify-mobile-release/classify-mobile-release.mjs b/.github/actions/classify-mobile-release/classify-mobile-release.mjs new file mode 100644 index 0000000..915698c --- /dev/null +++ b/.github/actions/classify-mobile-release/classify-mobile-release.mjs @@ -0,0 +1,204 @@ +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync } from 'node:fs'; + +const TAG_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$/; +const RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$/; +const NATIVE_PATHS = ['android', 'ios', 'capacitor.config.ts', 'capacitor.config.json']; + +export function parseArgs(argv) { + const args = new Map(); + for (let index = 0; index < argv.length; index += 2) args.set(argv[index], argv[index + 1]); + return args; +} + +export function parseTag(tag) { + return TAG_PATTERN.exec(tag); +} + +export function parseReleaseTag(tag) { + return RELEASE_TAG_PATTERN.exec(tag); +} + +export function releaseOrder([, major, minor, patch, prerelease]) { + return [Number(major), Number(minor), Number(patch), prerelease === undefined ? Number.MAX_SAFE_INTEGER : Number(prerelease)]; +} + +export function compareRelease(left, right) { + const leftOrder = releaseOrder(left); + const rightOrder = releaseOrder(right); + for (let index = 0; index < leftOrder.length; index += 1) { + if (leftOrder[index] !== rightOrder[index]) return leftOrder[index] - rightOrder[index]; + } + return 0; +} + +export function selectPreviousTag(tags, { currentTag, currentMatch, containsAppPackage }) { + return tags + .filter((candidate) => candidate !== `v${currentTag}`) + .map((candidate) => ({ candidate, match: parseReleaseTag(candidate) })) + .filter(({ match }) => match && compareRelease(match, currentMatch) < 0) + .filter(({ candidate }) => containsAppPackage(candidate)) + .sort((left, right) => compareRelease(right.match, left.match))[0]?.candidate; +} + +export function readNativeVersions(androidText, iosText) { + const androidVersion = /versionName\s+["'](\d+)\.(\d+)\.(\d+)["']/.exec(androidText); + const androidBuild = /versionCode\s+(\d+)/.exec(androidText)?.[1]; + const iosVersion = /MARKETING_VERSION\s*=\s*(\d+)\.(\d+)\.(\d+);/.exec(iosText); + const iosBuild = /CURRENT_PROJECT_VERSION\s*=\s*(\d+);/.exec(iosText)?.[1]; + if (!androidVersion || !androidBuild || !iosVersion || !iosBuild) { + throw new Error('Unable to read Android and iOS native versions/build numbers.'); + } + + const androidMarketingVersion = androidVersion.slice(1).join('.'); + const iosMarketingVersion = iosVersion.slice(1).join('.'); + if (androidMarketingVersion !== iosMarketingVersion || androidBuild !== iosBuild) { + throw new Error('Android and iOS native versions/build numbers must match.'); + } + return { marketingVersion: androidMarketingVersion, buildNumber: androidBuild }; +} + +export function expectedBuildPrefix(major, minor) { + return Number(major) * 100 + Number(minor); +} + +export function assertBuildEncodesVersion(buildNumber, major, minor) { + if (Math.floor(Number(buildNumber) / 10000) !== expectedBuildPrefix(major, minor)) { + throw new Error(`Native build number ${buildNumber} does not encode major/minor ${major}.${minor}.`); + } +} + +export function nativeDependencies(packageJson) { + return Object.fromEntries( + Object.entries({ ...packageJson.dependencies, ...packageJson.devDependencies }) + .filter(([name]) => name.includes('capacitor')) + .sort(([left], [right]) => left.localeCompare(right)), + ); +} + +export function hasNativeDependencyChanges(previousPackage, currentPackage) { + return JSON.stringify(nativeDependencies(previousPackage)) !== JSON.stringify(nativeDependencies(currentPackage)); +} + +export function classifyRelease({ + tagMatch, + native, + previousTag, + previousMatch, + previousNative, + nativeFilesChanged = [], + nativeDependencyChanges = false, +}) { + const [, major, minor, patch] = tagMatch; + assertBuildEncodesVersion(native.buildNumber, major, minor); + + const sameMajorMinor = previousMatch?.[1] === major && previousMatch?.[2] === minor; + if (!previousTag || !sameMajorMinor) { + const tagMarketingVersion = `${major}.${minor}.${patch}`; + if (native.marketingVersion !== tagMarketingVersion) { + throw new Error(`Store release ${tagMarketingVersion} must match native marketing version ${native.marketingVersion}.`); + } + if (previousNative && Number(native.buildNumber) <= Number(previousNative.buildNumber)) { + throw new Error( + `Store release must increment the native build number above ${previousNative.buildNumber}; got ${native.buildNumber}.`, + ); + } + return 'store'; + } + + if (nativeDependencyChanges || nativeFilesChanged.length > 0) { + const reasons = [...(nativeDependencyChanges ? ['Capacitor/native dependency changes'] : []), ...nativeFilesChanged]; + throw new Error( + `Patch/prerelease tags cannot contain native changes; bump the major or minor version for a store release:\n${reasons.join('\n')}`, + ); + } + + const [nativeMajor, nativeMinor, nativePatch] = native.marketingVersion.split('.').map(Number); + if (Number(major) !== nativeMajor || Number(minor) !== nativeMinor || Number(patch) < nativePatch) { + throw new Error(`Live Update tag ${tagMatch[0]} is not compatible with native ${native.marketingVersion}.`); + } + return 'live-update'; +} + +export function buildOutputLines(releaseKind, version, buildNumber) { + return [ + `release_kind=${releaseKind}`, + `version=${version}`, + `build_number=${buildNumber}`, + `production_channel=production-${buildNumber}`, + ]; +} + +function gitShow(path, tag) { + return execFileSync('git', ['show', `${tag}:${path}`], { encoding: 'utf8' }); +} + +export function main({ argv = process.argv.slice(2), env = process.env } = {}) { + const args = parseArgs(argv); + const appPath = (args.get('--app-path') ?? 'app').replace(/^\.\//, '').replace(/\/$/, ''); + const version = args.get('--tag')?.replace(/^v/, ''); + if (!version) throw new Error('A release tag is required.'); + const tagMatch = parseTag(version); + if (!tagMatch) throw new Error(`Invalid release tag: ${version}`); + + if (execFileSync('git', ['rev-parse', '--is-shallow-repository'], { encoding: 'utf8' }).trim() === 'true') { + throw new Error('Release classification requires complete Git history. Use actions/checkout with fetch-depth: 0.'); + } + + const native = readNativeVersions( + readFileSync(`${appPath}/android/app/build.gradle`, 'utf8'), + readFileSync(`${appPath}/ios/App/App.xcodeproj/project.pbxproj`, 'utf8'), + ); + const tagsText = execFileSync('git', ['tag', '--merged', 'HEAD'], { encoding: 'utf8' }).trim(); + const tags = tagsText ? tagsText.split('\n') : []; + const containsAppPackage = (candidate) => { + try { + execFileSync('git', ['cat-file', '-e', `${candidate}:${appPath}/package.json`], { stdio: 'ignore' }); + return true; + } catch { + return false; + } + }; + const previousTag = selectPreviousTag(tags, { currentTag: version, currentMatch: tagMatch, containsAppPackage }); + const previousMatch = previousTag ? parseReleaseTag(previousTag) : undefined; + + let previousNative; + let nativeDependencyChanges = false; + let nativeFilesChanged = []; + if (previousTag) { + const previousPackage = JSON.parse(gitShow(`${appPath}/package.json`, previousTag)); + const currentPackage = JSON.parse(readFileSync(`${appPath}/package.json`, 'utf8')); + nativeDependencyChanges = hasNativeDependencyChanges(previousPackage, currentPackage); + nativeFilesChanged = execFileSync( + 'git', + ['diff', '--name-only', previousTag, 'HEAD', '--', ...NATIVE_PATHS.map((path) => `:(top)${appPath}/${path}`)], + { encoding: 'utf8' }, + ) + .trim() + .split('\n') + .filter(Boolean); + try { + previousNative = readNativeVersions( + gitShow(`${appPath}/android/app/build.gradle`, previousTag), + gitShow(`${appPath}/ios/App/App.xcodeproj/project.pbxproj`, previousTag), + ); + } catch (error) { + if (previousMatch?.[1] === tagMatch[1] && previousMatch?.[2] === tagMatch[2]) throw error; + } + } + + const releaseKind = classifyRelease({ + tagMatch, + native, + previousTag, + previousMatch, + previousNative, + nativeFilesChanged, + nativeDependencyChanges, + }); + const lines = buildOutputLines(releaseKind, version, native.buildNumber); + if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); + console.log(`Classified v${version} as ${releaseKind} for native ${native.marketingVersion} (${native.buildNumber}).`); +} + +if (import.meta.url === `file://${process.argv[1]}`) main(); diff --git a/.github/actions/classify-mobile-release/classify-mobile-release.spec.mjs b/.github/actions/classify-mobile-release/classify-mobile-release.spec.mjs new file mode 100644 index 0000000..b4a57ab --- /dev/null +++ b/.github/actions/classify-mobile-release/classify-mobile-release.spec.mjs @@ -0,0 +1,264 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + assertBuildEncodesVersion, + buildOutputLines, + classifyRelease, + compareRelease, + hasNativeDependencyChanges, + nativeDependencies, + parseArgs, + parseReleaseTag, + parseTag, + readNativeVersions, + selectPreviousTag, +} from './classify-mobile-release.mjs'; + +const gradle = (version, build) => `defaultConfig { versionCode ${build}\nversionName "${version}" }`; +const pbxproj = (version, build) => `MARKETING_VERSION = ${version};\nCURRENT_PROJECT_VERSION = ${build};`; +const match = (version) => parseTag(version); +const releaseMatch = (version) => parseReleaseTag(`v${version}`); +const classification = (overrides = {}) => ({ + tagMatch: match('9.0.1'), + native: { marketingVersion: '9.0.0', buildNumber: '9000001' }, + previousTag: 'v9.0.0', + previousMatch: releaseMatch('9.0.0'), + previousNative: { marketingVersion: '9.0.0', buildNumber: '9000000' }, + nativeFilesChanged: [], + nativeDependencyChanges: false, + ...overrides, +}); + +describe('arguments and tags', () => { + it('parses action arguments', () => { + const args = parseArgs(['--app-path', 'app', '--tag', 'v9.1.0']); + assert.equal(args.get('--app-path'), 'app'); + assert.equal(args.get('--tag'), 'v9.1.0'); + }); + + it('accepts only the supported stable and numeric prerelease formats', () => { + assert.deepEqual(match('9.1.2')?.slice(1), ['9', '1', '2', undefined]); + assert.deepEqual(match('9.1.2-3')?.slice(1), ['9', '1', '2', '3']); + assert.equal(match('v9.1.2'), null); + assert.equal(match('9.1.2-beta.1'), null); + }); + + it('orders prereleases before stable releases and across major/minor', () => { + assert.ok(compareRelease(releaseMatch('9.1.0'), releaseMatch('9.0.99')) > 0); + assert.ok(compareRelease(releaseMatch('9.1.1-2'), releaseMatch('9.1.1-1')) > 0); + assert.ok(compareRelease(releaseMatch('9.1.1'), releaseMatch('9.1.1-2')) > 0); + }); +}); + +describe('previous release selection', () => { + const tags = ['not-a-release', 'v8.9.9', 'v9.0.0', 'v9.0.1-1', 'v9.0.1', 'v9.1.0']; + + it('selects the newest earlier applicable release across all major/minor versions', () => { + assert.equal( + selectPreviousTag(tags, { + currentTag: '9.1.0', + currentMatch: match('9.1.0'), + containsAppPackage: () => true, + }), + 'v9.0.1', + ); + }); + + it('skips the current/newer tags and tags that do not contain the app', () => { + assert.equal( + selectPreviousTag(tags, { + currentTag: '9.0.1', + currentMatch: match('9.0.1'), + containsAppPackage: (tag) => tag !== 'v9.0.1-1', + }), + 'v9.0.0', + ); + }); + + it('returns undefined for the first applicable release', () => { + assert.equal( + selectPreviousTag(['v9.0.0'], { + currentTag: '9.0.0', + currentMatch: match('9.0.0'), + containsAppPackage: () => true, + }), + undefined, + ); + }); +}); + +describe('native metadata', () => { + it('reads matching Android/iOS metadata', () => { + assert.deepEqual(readNativeVersions(gradle('9.2.0', '9020001'), pbxproj('9.2.0', '9020001')), { + marketingVersion: '9.2.0', + buildNumber: '9020001', + }); + }); + + it('rejects missing or inconsistent native metadata', () => { + assert.throws(() => readNativeVersions('', pbxproj('9.2.0', '9020001')), /Unable to read/); + assert.throws(() => readNativeVersions(gradle('9.2.0', '9020001'), pbxproj('9.2.1', '9020001')), /must match/); + assert.throws(() => readNativeVersions(gradle('9.2.0', '9020001'), pbxproj('9.2.0', '9020002')), /must match/); + }); + + it('checks the major/minor build-number encoding', () => { + assert.doesNotThrow(() => assertBuildEncodesVersion('9029999', '9', '2')); + assert.throws(() => assertBuildEncodesVersion('9030000', '9', '2'), /does not encode/); + }); +}); + +describe('native dependency detection', () => { + const previous = { + dependencies: { '@capacitor/core': '8.0.0', '@angular/core': '21.0.0' }, + devDependencies: { '@rdlabo/capacitor-brotherprint': '8.0.0' }, + }; + + it('selects Capacitor core and third-party plugin packages', () => { + assert.deepEqual(nativeDependencies(previous), { + '@capacitor/core': '8.0.0', + '@rdlabo/capacitor-brotherprint': '8.0.0', + }); + }); + + it('detects native dependency additions and version changes but ignores web dependencies', () => { + assert.equal(hasNativeDependencyChanges(previous, previous), false); + assert.equal( + hasNativeDependencyChanges(previous, { + ...previous, + dependencies: { ...previous.dependencies, '@angular/core': '21.1.0' }, + }), + false, + ); + assert.equal( + hasNativeDependencyChanges(previous, { + ...previous, + devDependencies: { '@rdlabo/capacitor-brotherprint': '8.1.0' }, + }), + true, + ); + }); +}); + +describe('release classification', () => { + it('routes patch, prerelease progression, and stable promotion to Live Update', () => { + assert.equal(classifyRelease(classification()), 'live-update'); + assert.equal( + classifyRelease( + classification({ + tagMatch: match('9.0.2-2'), + previousTag: 'v9.0.2-1', + previousMatch: releaseMatch('9.0.2-1'), + }), + ), + 'live-update', + ); + assert.equal( + classifyRelease( + classification({ + tagMatch: match('9.0.2'), + previousTag: 'v9.0.2-3', + previousMatch: releaseMatch('9.0.2-3'), + }), + ), + 'live-update', + ); + }); + + it('routes the first release and major/minor bumps to store publishing', () => { + assert.equal( + classifyRelease( + classification({ + tagMatch: match('9.0.0'), + native: { marketingVersion: '9.0.0', buildNumber: '9000000' }, + previousTag: undefined, + previousMatch: undefined, + previousNative: undefined, + }), + ), + 'store', + ); + assert.equal( + classifyRelease( + classification({ + tagMatch: match('9.1.0'), + native: { marketingVersion: '9.1.0', buildNumber: '9010000' }, + }), + ), + 'store', + ); + assert.equal( + classifyRelease( + classification({ + tagMatch: match('10.0.0'), + native: { marketingVersion: '10.0.0', buildNumber: '10000000' }, + }), + ), + 'store', + ); + }); + + it('requires store tag/native version equality and an increased build number', () => { + assert.throws( + () => classifyRelease(classification({ tagMatch: match('9.1.0'), native: { marketingVersion: '9.1.1', buildNumber: '9010000' } })), + /must match native marketing version/, + ); + assert.throws( + () => classifyRelease(classification({ tagMatch: match('9.1.0'), native: { marketingVersion: '9.1.0', buildNumber: '9000000' } })), + /does not encode/, + ); + assert.throws( + () => + classifyRelease( + classification({ + tagMatch: match('9.1.0'), + native: { marketingVersion: '9.1.0', buildNumber: '9010000' }, + previousNative: { marketingVersion: '9.0.0', buildNumber: '9010000' }, + }), + ), + /must increment/, + ); + assert.throws( + () => + classifyRelease( + classification({ + tagMatch: match('9.1.0'), + native: { marketingVersion: '9.1.0', buildNumber: '9010000' }, + previousNative: { marketingVersion: '9.0.0', buildNumber: '9020000' }, + }), + ), + /must increment/, + ); + }); + + it('rejects native files or dependency changes on patch/prerelease tags', () => { + assert.throws( + () => classifyRelease(classification({ nativeFilesChanged: ['app/ios/App/Podfile'] })), + /bump the major or minor.*app\/ios\/App\/Podfile/s, + ); + assert.throws(() => classifyRelease(classification({ nativeDependencyChanges: true })), /Capacitor\/native dependency changes/); + }); + + it('rejects a Live Update below the installed native patch', () => { + assert.throws( + () => + classifyRelease( + classification({ + tagMatch: match('9.0.1'), + native: { marketingVersion: '9.0.2', buildNumber: '9000001' }, + }), + ), + /not compatible/, + ); + }); +}); + +describe('action outputs', () => { + it('emits the routing and existing Live Update outputs', () => { + assert.deepEqual(buildOutputLines('live-update', '9.0.1', '9000000'), [ + 'release_kind=live-update', + 'version=9.0.1', + 'build_number=9000000', + 'production_channel=production-9000000', + ]); + }); +}); diff --git a/.github/actions/vitest.config.mjs b/.github/actions/vitest.config.mjs index b58ed1d..4b3565d 100644 --- a/.github/actions/vitest.config.mjs +++ b/.github/actions/vitest.config.mjs @@ -4,6 +4,7 @@ export default defineConfig({ cacheDir: '../../node_modules/.vitest-actions', test: { include: ['**/*.spec.mjs'], + exclude: ['classify-mobile-release/**/*.spec.mjs'], environment: 'node', root: import.meta.dirname, }, diff --git a/package.json b/package.json index b77d292..9c113f8 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "release": "np --no-tests --no-publish", "lint": "ng lint", "test:watch": "ng test", - "test:actions": "vitest run --config .github/actions/vitest.config.mjs", + "test:actions": "node --test .github/actions/classify-mobile-release/classify-mobile-release.spec.mjs && vitest run --config .github/actions/vitest.config.mjs", "e2e": "playwright test", "e2e:ui": "playwright test --ui" }, diff --git a/projects/kit/README.md b/projects/kit/README.md index 6fabe42..6c47219 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -23,23 +23,23 @@ Kit shares the repo `v*` release line with the other libraries (see root README ### Peer dependencies -| Package | Version | -|---|---| -| `@angular/common` | `^21.0.0` | -| `@angular/core` | `^21.0.0` | -| `@angular/router` | `^21.0.0` | -| `@ionic/angular` | `^8.0.0` | -| `@ionic/storage-angular` | `^4.0.0` | -| `@capacitor/core` | `>=6.0.0 <9.0.0` | -| `@capacitor/haptics` | `>=6.0.0 <9.0.0` | -| `@capacitor/keyboard` | `>=6.0.0 <9.0.0` | -| `@capacitor/network` | `>=6.0.0 <9.0.0` | -| `@capacitor/preferences` | `>=6.0.0 <9.0.0` | -| `@capacitor/status-bar` | `>=6.0.0 <9.0.0` | +| Package | Version | +| ------------------------------------ | ---------------- | +| `@angular/common` | `^21.0.0` | +| `@angular/core` | `^21.0.0` | +| `@angular/router` | `^21.0.0` | +| `@ionic/angular` | `^8.0.0` | +| `@ionic/storage-angular` | `^4.0.0` | +| `@capacitor/core` | `>=6.0.0 <9.0.0` | +| `@capacitor/haptics` | `>=6.0.0 <9.0.0` | +| `@capacitor/keyboard` | `>=6.0.0 <9.0.0` | +| `@capacitor/network` | `>=6.0.0 <9.0.0` | +| `@capacitor/preferences` | `>=6.0.0 <9.0.0` | +| `@capacitor/status-bar` | `>=6.0.0 <9.0.0` | | `@capacitor-community/in-app-review` | `>=6.0.0 <9.0.0` | -| `@rdlabo/capacitor-brotherprint` | `>=6.0.0 <9.0.0` | -| `dom-to-image-more` | `^3.0.0` | -| `rxjs` | `^7.8.0` | +| `@rdlabo/capacitor-brotherprint` | `>=6.0.0 <9.0.0` | +| `dom-to-image-more` | `^3.0.0` | +| `rxjs` | `^7.8.0` | Feature-scoped peers are only needed by the features that use them (`status-bar` → `KitThemeController`; `preferences` + `in-app-review` → `kitRequestReview`; `capacitor-brotherprint` + `dom-to-image-more` → the printer helpers); an app that doesn't use a feature can ignore its unmet-peer warning. @@ -61,9 +61,7 @@ import { IonicStorageModule } from '@ionic/storage-angular'; import { importProvidersFrom } from '@angular/core'; export const appConfig: ApplicationConfig = { - providers: [ - importProvidersFrom(IonicStorageModule.withConfig({ name: '__mydb' })), - ], + providers: [importProvidersFrom(IonicStorageModule.withConfig({ name: '__mydb' }))], }; ``` @@ -185,13 +183,13 @@ alertConfirm(options: { `watchKeyboard: true` (on `presentModal` options) expands a bottom sheet to full height when the native keyboard appears (iOS/Android only; no-op on web). -**How `presentModal` decides required vs. optional props.** Props are inferred from the component's `input()` fields, and whether each prop is **required** or **optional** is decided by a single rule: *does the input's type include `undefined`?* A default value is not "optional" — providing a default removes `undefined` from the input's type, so a defaulted input becomes a **required** prop. +**How `presentModal` decides required vs. optional props.** Props are inferred from the component's `input()` fields, and whether each prop is **required** or **optional** is decided by a single rule: _does the input's type include `undefined`?_ A default value is not "optional" — providing a default removes `undefined` from the input's type, so a defaulted input becomes a **required** prop. -| Declaration | Input type | Includes `undefined`? | Prop | -| ------------------------ | --------------- | --------------------- | --------------------------------- | -| `input.required()` | `T` | No | required | -| `input(defaultValue)` | `T` | No | **required** ← a default makes it required | -| `input()` (no arg) | `T \| undefined`| Yes | optional | +| Declaration | Input type | Includes `undefined`? | Prop | +| ------------------------ | ---------------- | --------------------- | ------------------------------------------ | +| `input.required()` | `T` | No | required | +| `input(defaultValue)` | `T` | No | **required** ← a default makes it required | +| `input()` (no arg) | `T \| undefined` | Yes | optional | To make a prop **optional**, drop the default and use a bare `input()` (its type is `T | undefined`), then apply your fallback where you read it (e.g. `this.enabled() ?? true`). If a component has at least one required input, the `componentProps` argument itself becomes mandatory; if it has no required inputs, `componentProps` may be omitted; a component with no `input()` fields at all accepts loose, untyped props. @@ -216,11 +214,11 @@ This centralizes presentation options, keeps component props and dismiss data ty Functional `CanActivateFn` guards for a four-state auth model: -| State | Meaning | -|---|---| -| `'user'` | Fully authenticated | -| `'confirm'` | Authenticated but email confirmation pending | -| `'required'` | Not authenticated | +| State | Meaning | +| ------------- | ---------------------------------------------------- | +| `'user'` | Fully authenticated | +| `'confirm'` | Authenticated but email confirmation pending | +| `'required'` | Not authenticated | | `'anonymous'` | Anonymous login active (can be prompted to register) | **Convention:** every redirect path is supplied via `provideKitAuth`; the kit does not hard-code any routes. `authState` and `redirects` are required. The app-specific hooks `onAuthorized` / `onUnauthenticated` are **optional** and default to `true` (allow the authenticated user through) / `false` (fall through to the `whenUnauthorized` redirect), so an app only supplies the ones with real logic. @@ -236,12 +234,12 @@ export const appConfig: ApplicationConfig = { provideKitAuth(() => { const auth = inject(AuthService); return { - authState: () => auth.state$, // Observable + authState: () => auth.state$, // Observable redirects: { - whenAuthorized: '/home', // kitRequiredUnauthorizedGuard - whenConfirming: '/auth/confirm', // kitRequiredUnauthorizedGuard - whenNotConfirming: '/auth/signin',// kitRequireConfirmingGuard - whenUnauthorized: '/auth', // kitRequireAuthorizedGuard + whenAuthorized: '/home', // kitRequiredUnauthorizedGuard + whenConfirming: '/auth/confirm', // kitRequiredUnauthorizedGuard + whenNotConfirming: '/auth/signin', // kitRequireConfirmingGuard + whenUnauthorized: '/auth', // kitRequireAuthorizedGuard }, // onAuthorized / onUnauthenticated omitted → defaults (allow / redirect). // Supply onAuthorized only when 'user' needs extra work (token login, permissions): @@ -277,11 +275,7 @@ const logged = await auth.tokenLogin().catch(async (e) => { ```typescript // routes.ts -import { - kitRequiredUnauthorizedGuard, - kitRequireConfirmingGuard, - kitRequireAuthorizedGuard, -} from '@rdlabo/ionic-angular-kit'; +import { kitRequiredUnauthorizedGuard, kitRequireConfirmingGuard, kitRequireAuthorizedGuard } from '@rdlabo/ionic-angular-kit'; export const routes: Routes = [ { @@ -364,6 +358,7 @@ export const appConfig: ApplicationConfig = { ``` **Error dispatch** (after retries, in `catchError`): + 1. `offlineFallback` non-null → return fallback observable (no further hooks called) 2. `401` → `onUnauthorized` · `403` → `onForbidden` 3. `0` (connected) → `onNetworkError` · `429` → `onRateLimited(retryAfter?)` · `502/503/504` → `onServerBusy(status, retryAfter?)` @@ -417,15 +412,10 @@ Sign-in / sign-up conveniences on `ion-input`: Fleet apps typically `storage.clear()` on sign-out. Pass keys that must survive (e.g. the last sign-in email): ```typescript -import { - KIT_LAST_AUTH_EMAIL_KEY, - KIT_THEME_STORAGE_KEY, - kitClearStoragePreservingKeys, -} from '@rdlabo/ionic-angular-kit'; +import { KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY, kitClearStoragePreservingKeys } from '@rdlabo/ionic-angular-kit'; await kitSignOut(auth, { - success: () => - kitClearStoragePreservingKeys(this.storage, [KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY]), + success: () => kitClearStoragePreservingKeys(this.storage, [KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY]), }); ``` @@ -542,7 +532,10 @@ import { kitPresentLanguageActionSheet } from '@rdlabo/ionic-angular-kit'; await kitPresentLanguageActionSheet(inject(ActionSheetController), { header: $localize`言語設定`, - locales: [{ text: 'English', data: 'en-US' }, { text: '日本語', data: 'ja' }], + locales: [ + { text: 'English', data: 'en-US' }, + { text: '日本語', data: 'ja' }, + ], cancelText: $localize`キャンセル`, currentLocale: normalizedLocale, currentPath: this.#router.url, @@ -567,7 +560,9 @@ import { kitDomToPng, kitBuildBrotherPrintSettings } from '@rdlabo/ionic-angular const png = await kitDomToPng(this.preview().nativeElement, { rotate: true }); const settings = kitBuildBrotherPrintSettings({ - modelName, printBase64: png, label, + modelName, + printBase64: png, + label, numberOfCopies: printOptions.printNum, halftoneThreshold: printOptions.halftoneThreshold, }); @@ -591,7 +586,11 @@ provideKitFirebaseAnalytics(), ```typescript import { inject, Injectable } from '@angular/core'; import { - KIT_FIREBASE_AUTH, kitSignIn, kitSignOut, kitResolveAuthStatus, kitReauthWithRetry, + KIT_FIREBASE_AUTH, + kitSignIn, + kitSignOut, + kitResolveAuthStatus, + kitReauthWithRetry, } from '@rdlabo/ionic-angular-kit/auth-firebase'; import { updatePassword } from 'firebase/auth'; // escape hatch for the reauth mutation @@ -653,7 +652,7 @@ export const appConfig: ApplicationConfig = { #### Release & channel model — 同じチャンネルのまま / チャンネルが変わる -配信は各アプリの `.github/workflows/live-update.yml` が `vX.Y.Z` / `vX.Y.Z-N` タグ push で発火し、共有 composite action([`ionic-angular-library/.github/actions`](https://github.com/rdlabo-team/ionic-angular-library/tree/main/.github/actions) の `validate-live-update` / `publish-live-update`)でバリデーション → バンドル署名 → アップロードまで自動実行します。 +配信は各アプリの release workflow が `vX.Y.Z` / `vX.Y.Z-N` タグ push で発火し、共有 composite action([`ionic-angular-library/.github/actions`](https://github.com/rdlabo-team/ionic-angular-library/tree/main/.github/actions) の `classify-mobile-release`)が直前のリリースタグと比較して配信経路を決めます。同じ `major.minor` の patch / prerelease は `publish-live-update`、`major` / `minor` 更新は Capawesome Cloud Native Builds + App Store Publishing へ進みます。 配信チャンネルは常に **`production-<ネイティブビルド番号>`**(Android `versionCode` = iOS `CURRENT_PROJECT_VERSION`、両者は一致必須)です。Live Update は「同じネイティブバイナリの上で JS/HTML/CSS だけを差し替える」仕組みなので、互換な端末にしか配信されないよう **ビルド番号ごとにチャンネルを分離** します(アップロード時に `--android-min/max` `--ios-min/max` をビルド番号へ固定)。 @@ -662,18 +661,18 @@ export const appConfig: ApplicationConfig = { 例)`9.0.0`(build `9000000`)→ Web だけ直して `9.0.1` → どちらも `production-9000000`。 - **チャンネルが変わる = ストアリリースが必要** - ネイティブビルド番号を **上げる** リリース。次のいずれかを含む場合:`app/android/**`・`app/ios/**`・`capacitor.config.ts`(または `.json`)の変更、または `@capacitor/*` / `@capawesome/capacitor-live-update` の **バージョン変更**。 - 例)ネイティブ更新で `9.1.0`(build `9100000`)→ 新チャンネル `production-9100000`。古い `9.0.x` 端末は `production-9000000` のまま影響を受けません。 + `major` または `minor` とネイティブビルド番号を **上げる** リリース。`app/android/**`・`app/ios/**`・`capacitor.config.ts`(または `.json`)の変更、または Capacitor plugin のバージョン変更はこのリリースへ含めます。iOS は TestFlight、Android は Google Play Internal track へ自動送信し、本番昇格は各ストアで行います。 + 例)ネイティブ更新で `9.1.0`(build `9010000`)→ 新チャンネル `production-9010000`。古い `9.0.x` 端末は `production-9000000` のまま影響を受けません。 ビルド番号は `major`・`minor` を先頭にエンコードします:`floor(ビルド番号 / 10000) === major * 100 + minor`。 | バージョン | ビルド番号 | チャンネル | | ---------- | ---------- | --------------------- | | `9.0.x` | `9000000` | `production-9000000` | -| `9.1.x` | `9100000` | `production-9100000` | +| `9.1.x` | `9010000` | `production-9010000` | | `10.2.x` | `10020000` | `production-10020000` | -`validate-live-update` は、直前の互換タグからネイティブ/設定/プラグイン依存が変わっているのに **ビルド番号を上げていない**(=ストア更新が必要なのに Live Update で流そうとしている)ケースを CI で失敗させ、事故を防ぎます。 +`classify-mobile-release` は、patch / prerelease にネイティブ・設定・Capacitor依存の変更が含まれていたら CI を失敗させ、`major` / `minor` bump を要求します。ストアリリースではタグと native marketing version の一致、Android/iOSのversion/build一致、ビルド番号の増加と上記エンコードを検証します。`validate-live-update` は既存consumer向けに互換維持します。 --- @@ -685,14 +684,7 @@ When testing a consumer app that declares `@rdlabo/ionic-angular-kit` as a `file // vitest.config.ts export default defineConfig({ resolve: { - dedupe: [ - '@angular/core', - '@angular/common', - '@angular/router', - '@ionic/angular', - '@ionic/core', - 'rxjs', - ], + dedupe: ['@angular/core', '@angular/common', '@angular/router', '@ionic/angular', '@ionic/core', 'rxjs'], }, test: { server: { @@ -701,7 +693,7 @@ export default defineConfig({ /@ionic\/angular/, /@ionic\/core/, /ionicons/, - /@rdlabo\/ionic-angular-kit/, // inline the kit itself + /@rdlabo\/ionic-angular-kit/, // inline the kit itself ], }, }, From 702793abaf12bdc64f2550b65e7cc759d234d1c7 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 15 Jul 2026 15:52:09 +0900 Subject: [PATCH 2/3] fix: harden release classifier inputs --- .github/actions/classify-mobile-release/action.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/classify-mobile-release/action.yml b/.github/actions/classify-mobile-release/action.yml index 09170c9..5177e0d 100644 --- a/.github/actions/classify-mobile-release/action.yml +++ b/.github/actions/classify-mobile-release/action.yml @@ -26,4 +26,7 @@ runs: steps: - id: classify shell: bash - run: node "$GITHUB_ACTION_PATH/classify-mobile-release.mjs" --app-path "${{ inputs.app-path }}" --tag "${{ inputs.tag }}" + env: + INPUT_APP_PATH: ${{ inputs.app-path }} + INPUT_TAG: ${{ inputs.tag }} + run: node "$GITHUB_ACTION_PATH/classify-mobile-release.mjs" --app-path "$INPUT_APP_PATH" --tag "$INPUT_TAG" From e87ba56e5772d3c8a75f2678d0da2274687c214a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 15 Jul 2026 15:53:37 +0900 Subject: [PATCH 3/3] docs: describe mobile releases in English --- projects/kit/README.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/projects/kit/README.md b/projects/kit/README.md index 6c47219..23ab775 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -650,29 +650,29 @@ export const appConfig: ApplicationConfig = { }; ``` -#### Release & channel model — 同じチャンネルのまま / チャンネルが変わる +#### Release and channel model -配信は各アプリの release workflow が `vX.Y.Z` / `vX.Y.Z-N` タグ push で発火し、共有 composite action([`ionic-angular-library/.github/actions`](https://github.com/rdlabo-team/ionic-angular-library/tree/main/.github/actions) の `classify-mobile-release`)が直前のリリースタグと比較して配信経路を決めます。同じ `major.minor` の patch / prerelease は `publish-live-update`、`major` / `minor` 更新は Capawesome Cloud Native Builds + App Store Publishing へ進みます。 +Each app's release workflow runs when a `vX.Y.Z` or `vX.Y.Z-N` tag is pushed. The shared `classify-mobile-release` composite action in [`ionic-angular-library/.github/actions`](https://github.com/rdlabo-team/ionic-angular-library/tree/main/.github/actions) compares the tag with the previous release and selects the delivery path. Patch and prerelease updates within the same `major.minor` line use `publish-live-update`; major and minor updates use Capawesome Cloud Native Builds and App Store Publishing. -配信チャンネルは常に **`production-<ネイティブビルド番号>`**(Android `versionCode` = iOS `CURRENT_PROJECT_VERSION`、両者は一致必須)です。Live Update は「同じネイティブバイナリの上で JS/HTML/CSS だけを差し替える」仕組みなので、互換な端末にしか配信されないよう **ビルド番号ごとにチャンネルを分離** します(アップロード時に `--android-min/max` `--ios-min/max` をビルド番号へ固定)。 +Every delivery channel is named **`production-`**, where the Android `versionCode` and iOS `CURRENT_PROJECT_VERSION` must match. A Live Update replaces only the JS, HTML, and CSS on an existing native binary, so channels are isolated by build number and updates reach only compatible devices. The upload pins `--android-min/max` and `--ios-min/max` to that build number. -- **同じチャンネルのまま = Live Update で配信できる** - ネイティブビルド番号を **変えない** リリース。JS/HTML/CSS のみの変更(バグ修正・文言・UI・ロジック、ネイティブに影響しない npm 依存)。同じ `major.minor` で patch を上げるだけならチャンネルは据え置きで、既存ユーザーはストア更新なしで最新化されます。 - 例)`9.0.0`(build `9000000`)→ Web だけ直して `9.0.1` → どちらも `production-9000000`。 +- **Same channel: eligible for Live Update** + Keep the native build number unchanged. This path is for JS, HTML, and CSS changes only, including bug fixes, copy, UI, application logic, and npm dependencies that do not affect native code. Incrementing only the patch version within the same `major.minor` line keeps the channel unchanged, so existing users receive the update without installing a new store build. + Example: `9.0.0` (build `9000000`) followed by a web-only `9.0.1` update; both use `production-9000000`. -- **チャンネルが変わる = ストアリリースが必要** - `major` または `minor` とネイティブビルド番号を **上げる** リリース。`app/android/**`・`app/ios/**`・`capacitor.config.ts`(または `.json`)の変更、または Capacitor plugin のバージョン変更はこのリリースへ含めます。iOS は TestFlight、Android は Google Play Internal track へ自動送信し、本番昇格は各ストアで行います。 - 例)ネイティブ更新で `9.1.0`(build `9010000`)→ 新チャンネル `production-9010000`。古い `9.0.x` 端末は `production-9000000` のまま影響を受けません。 +- **New channel: store release required** + Increment the major or minor version and the native build number. Include changes to `app/android/**`, `app/ios/**`, `capacitor.config.ts` (or `.json`), and Capacitor plugin versions in this release type. The workflow submits iOS builds to TestFlight and Android builds to the Google Play Internal track; promotion to production happens in each store. + Example: a native update to `9.1.0` (build `9010000`) creates `production-9010000`. Devices still running `9.0.x` remain on `production-9000000` and are unaffected. -ビルド番号は `major`・`minor` を先頭にエンコードします:`floor(ビルド番号 / 10000) === major * 100 + minor`。 +The build number encodes the major and minor versions at the front: `floor(buildNumber / 10000) === major * 100 + minor`. -| バージョン | ビルド番号 | チャンネル | -| ---------- | ---------- | --------------------- | -| `9.0.x` | `9000000` | `production-9000000` | -| `9.1.x` | `9010000` | `production-9010000` | -| `10.2.x` | `10020000` | `production-10020000` | +| Version | Build number | Channel | +| -------- | ------------ | --------------------- | +| `9.0.x` | `9000000` | `production-9000000` | +| `9.1.x` | `9010000` | `production-9010000` | +| `10.2.x` | `10020000` | `production-10020000` | -`classify-mobile-release` は、patch / prerelease にネイティブ・設定・Capacitor依存の変更が含まれていたら CI を失敗させ、`major` / `minor` bump を要求します。ストアリリースではタグと native marketing version の一致、Android/iOSのversion/build一致、ビルド番号の増加と上記エンコードを検証します。`validate-live-update` は既存consumer向けに互換維持します。 +`classify-mobile-release` fails CI when a patch or prerelease contains native, configuration, or Capacitor dependency changes and requires a major or minor bump instead. For store releases, it verifies that the tag matches the native marketing version, the Android and iOS versions and build numbers agree, the build number increases, and the encoding above is valid. `validate-live-update` remains available for compatibility with existing consumers. ---