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
187 changes: 187 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
name: CI/CD

on:
push:
branches:
- master
pull_request:
branches:
- master

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Test (PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version: ["7.4", "8.0", "8.1", "8.2", "8.3", "8.4"]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: pcov
tools: composer:v2
extensions: mbstring, openssl, simplexml, json

- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.cache/composer
key: composer-${{ matrix.php-version }}-${{ hashFiles('composer.json') }}
restore-keys: composer-${{ matrix.php-version }}-

# `composer update` (not install) so each matrix entry resolves deps
# compatible with its PHP version — the lockfile pins packages that
# require PHP 8.2+ and would otherwise fail on 7.4.
- name: Install dependencies
run: composer update --no-interaction --no-progress --prefer-dist --with-all-dependencies

- name: Validate composer.json
run: composer validate --strict --no-check-lock

- name: Run PHPUnit with coverage
run: ./vendor/bin/phpunit --coverage-clover coverage.xml --coverage-text

- name: Enforce >= 80% line coverage
run: |
php -r '
if (!file_exists("coverage.xml")) {
fwrite(STDERR, "coverage.xml not produced\n"); exit(1);
}
$xml = new SimpleXMLElement(file_get_contents("coverage.xml"));
$m = $xml->project->metrics;
$stmts = (int) $m["statements"];
$covered = (int) $m["coveredstatements"];
$pct = $stmts > 0 ? ($covered / $stmts) * 100 : 0.0;
printf("Line coverage: %.2f%% (%d / %d)\n", $pct, $covered, $stmts);
if ($pct < 80.0) {
fwrite(STDERR, "FAIL: coverage is below the 80% threshold.\n");
exit(1);
}
'

# Only the canonical PHP version uploads the report for the deploy job
# to consume — and only on master pushes where deploy will actually run.
- name: Upload coverage report
if: matrix.php-version == '8.3' && github.ref == 'refs/heads/master' && github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: coverage-clover
path: coverage.xml
retention-days: 7

deploy:
name: Deploy (tag + badges)
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
permissions:
contents: write
steps:
- name: Checkout master with full history
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer:v2
extensions: simplexml, json

- name: Read library version
id: version
run: |
VERSION=$(php -r 'require "src/Version.php"; echo Dev1\\NotifyCore\\Version::VERSION;')
if [ -z "$VERSION" ]; then
echo "::error::Unable to read Dev1\\NotifyCore\\Version::VERSION"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved version: $VERSION"

- name: Check whether tag already exists
id: tag_check
run: |
if git rev-parse --verify "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${{ steps.version.outputs.tag }} already exists — skipping release."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Create and push version tag
if: steps.tag_check.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
git push origin "${{ steps.version.outputs.tag }}"
echo "Pushed ${{ steps.version.outputs.tag }} — Packagist webhook will publish automatically."

- name: Download coverage report
uses: actions/download-artifact@v4
with:
name: coverage-clover
path: .

- name: Compute coverage percentage
id: cov
run: |
PCT=$(php -r '
$xml = new SimpleXMLElement(file_get_contents("coverage.xml"));
$m = $xml->project->metrics;
$s = (int) $m["statements"];
$c = (int) $m["coveredstatements"];
printf("%.1f", $s > 0 ? ($c / $s) * 100 : 0.0);
')
echo "pct=$PCT" >> "$GITHUB_OUTPUT"
echo "Coverage: $PCT%"

- name: Build shields.io endpoint JSON
run: |
PCT='${{ steps.cov.outputs.pct }}'
COLOR=$(awk -v p="$PCT" 'BEGIN {
if (p+0 >= 90) print "brightgreen";
else if (p+0 >= 80) print "green";
else if (p+0 >= 70) print "yellow";
else print "red";
}')
mkdir -p badges-out
printf '{"schemaVersion":1,"label":"coverage","message":"%s%%","color":"%s"}\n' \
"$PCT" "$COLOR" > badges-out/coverage.json
cat badges-out/coverage.json

- name: Publish coverage badge to `badges` branch
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./badges-out
publish_branch: badges
keep_files: true
commit_message: "chore(badges): refresh coverage (${{ steps.cov.outputs.pct }}%)"

- name: Job summary
run: |
{
echo "### Deploy summary"
echo ""
echo "- **Version:** \`${{ steps.version.outputs.version }}\`"
if [ "${{ steps.tag_check.outputs.exists }}" = "false" ]; then
echo "- **Tag published:** \`${{ steps.version.outputs.tag }}\` (Packagist will pick it up via webhook)"
else
echo "- **Tag published:** skipped — \`${{ steps.version.outputs.tag }}\` already exists"
fi
echo "- **Coverage:** \`${{ steps.cov.outputs.pct }}%\`"
} >> "$GITHUB_STEP_SUMMARY"
1 change: 1 addition & 0 deletions .phpunit.result.cache
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":1,"defects":{"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTransportExceptionReturnsTransportErrorResult":4},"times":{"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testSuccessDefaults":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testFailurePropagatesErrorFields":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testRejectsEmptyTarget":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testStoresTokenAsString":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testStoresTopic":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testStoresCondition":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testConstructorRequiresProjectId":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testEndpointInterpolatesProjectId":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testSuccessfulSendReturnsId":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testFailureMapsFcmErrorStatus":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testDataOnlyPushOmitsNotificationBlock":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testDataValuesAreStringified":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTargetPrefersToken":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testAndroidOptionsAreSerialized":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTransportExceptionReturnsTransportErrorResult":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testFirstRegisteredBecomesDefault":0.001,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testAsDefaultSwitchesDefault":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testUnknownClientThrows":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testRemoveClearsDefault":0,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testRequiresEmailAndKey":0.008,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testSuccessfulTokenIsCachedInMemory":0.004,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testPsr16CacheIsPopulatedAndReused":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testRetriesOn5xxThenSucceeds":0.002,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testRetriesOnTransportExceptionThenSucceeds":0.002,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testExhaustedRetriesThrowTransient":0.003,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testNon5xxErrorDoesNotRetry":0.001,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testUnregisteredHelper":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testInvalidArgumentHelper":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testQuotaExceededHelper":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushResultTest::testTransientHelper":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testRejectsAmbiguousTarget":0,"Dev1\\NotifyCore\\Tests\\DTO\\PushTargetTest::testRejectsAllThreeSet":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testRetriesOn503ThenSucceeds":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testRetriesOnTransportException":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testRetriesOn429":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testExhaustedRetriesReturnsTransientResult":0.001,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testClientErrorDoesNotRetry":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testFcmErrorCodeDetailOverridesStatus":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientRetryTest::testPushResultTransientHelper":0.001,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testPriorityNormalizes":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testPriorityRejectsUnknownValue":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testChannelIdEmittedExactlyOnce":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testTtlIsSecondsString":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testRemoveUnknownReturnsFalse":0,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testMalformedCacheEntryIsIgnored":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testCacheReadFailureFallsBackToNetwork":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testCacheWriteFailureIsSwallowed":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testScopeArrayIsSpaceJoinedInJwt":0.002,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testHttp200WithoutAccessTokenThrows":0.001,"Dev1\\NotifyCore\\Tests\\Auth\\GoogleServiceAccountTokenProviderTest::testInvalidPrivateKeyThrowsAtSignTime":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTopicTargetSerializesAsTopic":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testConditionTargetSerializesAsCondition":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testApnsPlatformOptionsAreSerialized":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testRawArrayOverridesArePassedThroughUntouched":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testTokenAcquisitionFailureReturnsTokenErrorResult":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testNonJsonErrorBodyFallsBackToHttpStatusCode":0,"Dev1\\NotifyCore\\Tests\\Drivers\\FcmHttpV1ClientTest::testCustomEndpointInterpolatesProjectId":0,"Dev1\\NotifyCore\\Tests\\Factory\\FcmClientFactoryTest::testCreateReturnsFcmHttpV1Client":0,"Dev1\\NotifyCore\\Tests\\Factory\\FcmClientFactoryTest::testCreatePropagatesConfigValidation":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testNegativeTtlClampsToZero":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testWithNotificationMergesIntoNotificationMap":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testChannelIdCoexistsWithNotificationMap":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testWithDataReplacesPreviousData":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testWithExtraMergesIntoOutputRoot":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testMergePrefersOtherForScalarsAndMergesMaps":0,"Dev1\\NotifyCore\\Tests\\Platform\\AndroidOptionsTest::testMergeOtherOverridesChannelAndTtl":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testEmptyOptionsProduceEmptyArray":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testHeadersOnlyEmitsHeadersKey":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testApsIsNestedUnderBody":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testCustomPayloadMergesAtBodyRoot":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testWithHeadersMergesRecursively":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testMergePrefersOtherForScalarsAndMergesMaps":0,"Dev1\\NotifyCore\\Tests\\Platform\\ApnsOptionsTest::testCustomWithoutApsStillProducesBody":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testSetDefaultSwitchesDefault":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testSetDefaultUnknownThrows":0,"Dev1\\NotifyCore\\Tests\\Registry\\ClientRegistryTest::testNamesListsRegisteredClients":0}}
39 changes: 37 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [1.2] - 2026-04-18
### Added
- Optional PSR-16 token cache on `GoogleServiceAccountTokenProvider` (new `?CacheInterface $cache` constructor arg + `cache_key` config) so OAuth tokens can be shared across processes/requests.
- Automatic retry with exponential backoff + jitter on transient failures (5xx, 429, PSR-18 transport errors) in both the token provider and `FcmHttpV1Client`. Configurable via `max_retries` and `retry_base_delay_ms`.
- `FcmHttpV1Client` now extracts `error.details[*].errorCode` (e.g. `UNREGISTERED`, `QUOTA_EXCEEDED`) in preference to the generic `error.status`.
- `PushResult` helpers: `isUnregistered()`, `isInvalidArgument()`, `isQuotaExceeded()`, `isTransient()`.
- Data-only (silent) push support: `notification` block is omitted when `PushMessage` has empty title and body.
- `Dev1\NotifyCore\Version` constants + automatic `User-Agent: Dev1-Notify-Core/<ver>` header on every FCM and OAuth request.
- PHPUnit configuration (`phpunit.xml.dist`) and initial test suite covering DTOs, registry, FCM driver, token provider, retry behavior, and Android options.
- GitHub Actions CI/CD pipeline (`.github/workflows/ci.yml`): matrix tests on PHP 7.4–8.4 with pcov, enforces ≥80% line coverage on every push/PR, and on merges to `master` auto-publishes the `v<Version::VERSION>` tag (consumed by the Packagist webhook) and refreshes the shields.io coverage badge on the `badges` branch.

### Changed
- **Breaking:** `PushTarget` now throws `InvalidArgumentException` when more than one of `token`/`topic`/`condition` is provided (previously the driver silently preferred `token`).
- **Breaking:** `AndroidOptions::withPriority()` throws on unknown values instead of silently passing them through.
- `FcmHttpV1Client` no longer catches generic `\Throwable`; only PSR-18 `ClientExceptionInterface` is caught and surfaced as `TRANSPORT_ERROR`.
- Token acquisition errors now return a `PushResult` with `errorCode = TOKEN_ERROR` instead of crashing the send.
- `AndroidOptions::toArray()` emits `channel_id` exactly once.
- Missing return types added to `AndroidOptions::withChannelId()`, `AndroidOptions::merge()`, and `ApnsOptions::merge()`.
- `PushResult::$id` PHPDoc corrected to `string|null`.
- `ClientRegistry::remove()` now returns `bool` (true if a client was removed, false if unknown) while preserving idempotent semantics.
- `AccessTokenProvider::getToken()` PHPDoc now documents the `@throws` contract for failed acquisitions.
- Widened `psr/log` constraint to `^1.1 || ^2.0 || ^3.0` for compatibility with modern logger implementations.

### Fixed
- `FcmClientFactory::create` no longer uses a PHP 8-only trailing comma; library parses cleanly on PHP 7.4 again.
- `json_encode` failures in `buildAssertionJwt()` and the FCM payload are now surfaced explicitly instead of signing/sending the literal `false`.
- Non-scalar values in `PushMessage::$data` whose `json_encode` fails fall back to an empty string instead of the literal `"false"`.
- Dropped the deprecated `openssl_pkey_free()` call (no-op on PHP 8.x, GC handles both 7.4 and 8.x).
- Removed an unreachable `return ''` in `GoogleServiceAccountTokenProvider::getToken()`.

### Removed
- Orphan `Dev1\NotifyCore\Message` and `Dev1\NotifyCore\Builders\MessageBuilder` classes (never wired into the driver).
- Undocumented `timeout` config key on `FcmHttpV1Client` (it was never applied).

---

## [Unreleased]
- Support for additional providers (Twilio Notify, OneSignal, APNs).
- Adapters for Laravel and Symfony.
- Unit tests and CI/CD pipeline.
- Adapters for Symfony.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
Driver-agnostic notifications core for PHP (starting with Firebase Cloud Messaging HTTP v1).
Built in Mexico by **DEV1 Softworks Labs**

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Version: 1.1](https://img.shields.io/badge/version-1.1-green.svg)](#)
[![CI/CD](https://github.com/DEV1-Softworks/notify-core/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/DEV1-Softworks/notify-core/actions/workflows/ci.yml)
[![Packagist Version](https://img.shields.io/packagist/v/dev1/notify-core.svg)](https://packagist.org/packages/dev1/notify-core)
[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/DEV1-Softworks/notify-core/badges/coverage.json)](https://github.com/DEV1-Softworks/notify-core/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

---

Expand Down Expand Up @@ -103,8 +106,10 @@ var_dump($result);
- [ ] Add more drivers (Twilio Notify, OneSignal, APNs)
- [x] Laravel adapter (`dev1/laravel-notify`)
- [ ] Symfony bundle (`dev1/symfony-notify-bundle`)
- [ ] Unit tests & CI
- [x] Unit tests
- [x] CI pipeline
- [x] Advanced platform overrides (Android/APNs)
- [x] Optional PSR-16 token cache + automatic retries on transient failures

---

Expand Down
16 changes: 14 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,30 @@
"php": ">=7.4 <8.5",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/log": "^1.1"
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"require-dev": {
"nyholm/psr7": "^1.8",
"symfony/http-client": "^5.4 || ^6.0 || ^7.0",
"phpunit/phpunit": "^9.6"
"phpunit/phpunit": "^9.6",
"psr/simple-cache": "^1.0"
},
"suggest": {
"psr/simple-cache": "Share Google OAuth tokens across requests (PSR-16)."
},
"autoload": {
"psr-4": {
"Dev1\\NotifyCore\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Dev1\\NotifyCore\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit"
},
"authors": [
{
"name": "DEV1 Softworks Labs",
Expand Down
Loading
Loading