Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:
[ "$verdict" = "version-only" ] && echo "code=false" >> "$GITHUB_OUTPUT" || echo "code=true" >> "$GITHUB_OUTPUT"

checks:
name: Lint · Typecheck · Build · Test
name: Lint · Typecheck · Build · Test + coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -87,7 +87,17 @@ jobs:
- run: npm run typecheck
- run: npm run build
- run: npm run build:plugin
# Runs the full Jest suite with coverage; the coverageThreshold in
# package.json fails this step if the lib (src + plugin/src) regresses
# below the enforced floor.
- run: npm run test:ci
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
if-no-files-found: ignore

plugin-prebuild:
name: Config plugin · prebuild assertions + JS bundle
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,9 @@ jobs:
npm run test:ci
npm run build
npm run build:plugin
- name: Assert tarball contents
# A published version can't be overwritten, so fail loudly here if the
# tarball is missing a required file or leaking source/secrets/tests.
run: node internal/module_scripts/verify-pack.js
- name: Publish (tokenless via OIDC, with provenance)
run: npm publish
42 changes: 42 additions & 0 deletions .github/workflows/sdk-staleness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: MyID SDK staleness

# Weekly check: are the pinned MyID SDK versions (iOS MyIdSDK, Android
# myid-capture-sdk) still the newest STABLE upstream? Betas are ignored on
# purpose — this package pins exact stables. On drift, opens/updates a single
# tracking issue. Run manually any time via "Run workflow".

on:
schedule:
- cron: '17 6 * * 1' # Mondays 06:17 UTC
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
check:
name: Check pinned SDKs vs latest stable
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Compare pins against upstream
id: check
run: node internal/module_scripts/check-sdk-staleness.js
- name: Open or update tracking issue
if: steps.check.outputs.stale == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
TITLE="MyID SDK update available"
existing="$(gh issue list --state open --search "$TITLE in:title" --json number -q '.[0].number // empty')"
if [ -n "$existing" ]; then
echo "Updating existing issue #$existing"
gh issue comment "$existing" --body-file sdk-staleness-report.md
else
echo "Opening new tracking issue"
gh issue create --title "$TITLE" --body-file sdk-staleness-report.md
fi
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,9 @@ yarn-error.log

# TypeScript incremental build cache
*.tsbuildinfo

# Jest coverage output
coverage/

# Generated by the SDK-staleness workflow when a bump is available
sdk-staleness-report.md
159 changes: 159 additions & 0 deletions internal/module_scripts/__tests__/check-sdk-staleness.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Unit tests for the pure version-comparison helpers behind the weekly SDK
// staleness check. A bug in compareVersions/latestStable would either miss a
// real upstream bump or (worse) flag a beta as an update, so the numeric
// ordering and pre-release filtering are locked down here. `readPin` is also
// tested against the REAL pinned files, doubling as a regression guard that the
// podspec/gradle pin formats still match the extraction regexes.
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
compareVersions,
latestStable,
isStable,
readPin,
parseIosVersions,
parseAndroidVersions,
buildStalenessReport,
emit,
} = require('../check-sdk-staleness');

describe('compareVersions', () => {
it('orders by numeric dot segments (not lexicographically)', () => {
expect(compareVersions('2.4.91', '2.4.9')).toBeGreaterThan(0);
expect(compareVersions('3.1.10', '3.1.9')).toBeGreaterThan(0);
expect(compareVersions('3.0.0', '3.1.0')).toBeLessThan(0);
});

it('returns 0 for equal versions', () => {
expect(compareVersions('3.1.3', '3.1.3')).toBe(0);
});

it('treats missing trailing segments as zero', () => {
expect(compareVersions('3.1', '3.1.0')).toBe(0);
expect(compareVersions('3.1.1', '3.1')).toBeGreaterThan(0);
expect(compareVersions('3.1', '3.1.1')).toBeLessThan(0);
});
});

describe('isStable', () => {
it('accepts plain semver and rejects any pre-release', () => {
expect(isStable('3.1.9')).toBe(true);
expect(isStable('3.1.3')).toBe(true);
expect(isStable('3.1.10-beta02')).toBe(false);
expect(isStable('3.1.10-rc.1')).toBe(false);
});
});

describe('latestStable', () => {
it('returns the newest stable, ignoring pre-releases', () => {
expect(latestStable(['3.1.2', '3.1.9', '3.1.10-beta02', '3.1.3'])).toBe('3.1.9');
});

it('ignores ordering of the input', () => {
expect(latestStable(['3.1.10-beta02', '3.1.3', '3.1.9'])).toBe('3.1.9');
});

it('throws when there are no stable versions', () => {
expect(() => latestStable(['1.0.0-beta', '2.0.0-rc.1'])).toThrow(/no stable/);
});
});

describe('readPin', () => {
it('extracts the iOS MyIdSDK pin from the real podspec', () => {
const pin = readPin('ios/MyId.podspec', /MyIdSDK['"]?\s*,\s*['"]([0-9][^'"]*)['"]/);
expect(pin).toMatch(/^\d+\.\d+\.\d+$/);
});

it('extracts the Android myid-capture-sdk pin from the real build.gradle', () => {
const pin = readPin('android/build.gradle', /myid-capture-sdk:([0-9][^"']*)/);
expect(pin).toMatch(/^\d+\.\d+\.\d+$/);
});

it('throws a helpful error when the pin cannot be found', () => {
expect(() => readPin('package.json', /THIS_PATTERN_WONT_MATCH_([0-9]+)/)).toThrow(
/could not read a pinned version/
);
});
});

describe('parseIosVersions', () => {
it('extracts version names from the CocoaPods trunk JSON shape', () => {
const json = JSON.stringify({
versions: [{ name: '3.1.2' }, { name: '3.1.3' }],
});
expect(parseIosVersions(json)).toEqual(['3.1.2', '3.1.3']);
});

it('composes with latestStable to pick the newest stable', () => {
const json = JSON.stringify({
versions: [{ name: '3.1.2' }, { name: '3.1.3' }, { name: '3.1.4-beta1' }],
});
expect(latestStable(parseIosVersions(json))).toBe('3.1.3');
});
});

describe('parseAndroidVersions', () => {
it('extracts every <version> from the maven-metadata XML', () => {
const xml = `<metadata><versioning><versions>
<version>3.1.8</version>
<version>3.1.9</version>
<version>3.1.10-beta02</version>
</versions></versioning></metadata>`;
expect(parseAndroidVersions(xml)).toEqual(['3.1.8', '3.1.9', '3.1.10-beta02']);
});

it('composes with latestStable to ignore the beta metadata tag', () => {
const xml = `<version>3.1.9</version><version>3.1.10-beta02</version>`;
expect(latestStable(parseAndroidVersions(xml))).toBe('3.1.9');
});
});

describe('buildStalenessReport', () => {
const upToDateRows = [
{ platform: 'iOS · MyIdSDK', file: 'ios/MyId.podspec', pinned: '3.1.3', latest: '3.1.3' },
{ platform: 'Android', file: 'android/build.gradle', pinned: '3.1.9', latest: '3.1.9' },
];

it('reports not stale when every pin equals the latest stable', () => {
const { stale, report } = buildStalenessReport(upToDateRows);
expect(stale).toBe(false);
expect(report).toContain('✅');
expect(report).not.toContain('update available');
});

it('flags stale when any pin is behind the latest stable', () => {
const rows = [
{ platform: 'iOS · MyIdSDK', file: 'ios/MyId.podspec', pinned: '3.1.3', latest: '3.1.3' },
{ platform: 'Android', file: 'android/build.gradle', pinned: '3.1.9', latest: '3.1.10' },
];
const { stale, report } = buildStalenessReport(rows);
expect(stale).toBe(true);
expect(report).toContain('⚠️');
expect(report).toContain('update available');
expect(report).toContain('android/build.gradle');
});
});

describe('emit', () => {
const original = process.env.GITHUB_OUTPUT;
afterEach(() => {
if (original === undefined) {
delete process.env.GITHUB_OUTPUT;
} else {
process.env.GITHUB_OUTPUT = original;
}
});

it('appends "name=value" to the GITHUB_OUTPUT file when set', () => {
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'myid-emit-')), 'out.txt');
process.env.GITHUB_OUTPUT = file;
emit('stale', true);
expect(fs.readFileSync(file, 'utf8')).toBe('stale=true\n');
});

it('is a no-op when GITHUB_OUTPUT is not set', () => {
delete process.env.GITHUB_OUTPUT;
expect(() => emit('stale', false)).not.toThrow();
});
});
64 changes: 64 additions & 0 deletions internal/module_scripts/__tests__/verify-pack.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Unit tests for the pre-publish tarball verifier. `checkTarball` is the pure
// core of internal/module_scripts/verify-pack.js — it decides, from the list of
// packed paths, whether a release is safe to publish. A regression here could
// ship source/secrets or omit the README, and a published version can never be
// overwritten, so this logic is worth locking down.
const { checkTarball, REQUIRED, FORBIDDEN } = require('../verify-pack');

describe('checkTarball', () => {
it('reports no problems for a tarball with every required file and nothing forbidden', () => {
expect(checkTarball([...REQUIRED])).toEqual([]);
});

it('flags each missing required file', () => {
const withoutReadme = REQUIRED.filter((f) => f !== 'README.md');
const problems = checkTarball(withoutReadme);
expect(problems).toContain('missing required file: README.md');
});

it('reports every missing required file, not just the first', () => {
const problems = checkTarball([]);
expect(problems).toHaveLength(REQUIRED.length);
for (const req of REQUIRED) {
expect(problems).toContain(`missing required file: ${req}`);
}
});

it.each([
['.env', /environment \/ secrets file/],
['config/.env.production', /environment \/ secrets file/],
['example/App.tsx', /example app/],
['build/__tests__/foo.js', /test files/],
['build/index.test.js', /test file/],
['src/index.ts', /TypeScript source/],
['plugin/src/index.ts', /plugin TypeScript source/],
['node_modules/lodash/index.js', /bundled dependencies/],
['plugin/tsconfig.tsbuildinfo', /incremental build cache/],
])('flags forbidden path %s', (path, reasonPattern) => {
const problems = checkTarball([...REQUIRED, path]);
expect(problems.some((p) => reasonPattern.test(p))).toBe(true);
});

it('allows the native android/src Kotlin sources (consumed by autolinking)', () => {
// android/src/... is intentionally NOT matched by the ^src/ forbidden rule.
const problems = checkTarball([...REQUIRED]);
expect(problems).toEqual([]);
expect(
REQUIRED.includes('android/src/main/java/uz/softwhere/myid/MyIdModule.kt')
).toBe(true);
});
});

describe('FORBIDDEN rules', () => {
it('does not treat android/src paths as forbidden source', () => {
const androidSrc = 'android/src/main/java/uz/softwhere/myid/MyIdModule.kt';
const matched = FORBIDDEN.filter((rule) => rule.re.test(androidSrc));
expect(matched).toEqual([]);
});

it('anchors the src rule at the repo root (does not match build/src style paths accidentally)', () => {
const srcRule = FORBIDDEN.find((r) => r.why.includes('TypeScript source'));
expect(srcRule.re.test('src/index.ts')).toBe(true);
expect(srcRule.re.test('android/src/main/Foo.kt')).toBe(false);
});
});
Loading
Loading