From 09c200d6597ece4b68cbf681447f740772378cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 21 Sep 2026 12:52:03 +0200 Subject: [PATCH 1/2] Test the fbc/fbp parsing against Meta's own parameter builder facebook/capi-param-builder-php is added as a dev dependency and used as a test oracle: every _fbc and _fbp value the builder writes must be parsed by Fbc/Fbp and written back byte for byte. The cases are a new fbc from an fbclid (including base64url click ids), a replaced fbc, a generated fbp, legacy cookies the builder upgrades with an appendix, cookies it passes through unchanged (every appendix kind and the six legacy language tokens), and hosts that yield different subdomain indexes. The check is deliberately one-directional, since the builder's own parser is only structural and accepts 'a.b.c.d'. The oracle found a gap straight away: for a deep host such as a.b.example.co.uk the builder writes fb.3., while the value objects only accepted a subdomain index of 0, 1 or 2 and rejected the cookie. The subdomain index is the number of dots in the cookie domain, so any non-negative index without a leading zero is now accepted. Dependabot also watches the new package, and the build workflow runs weekly: no composer.lock is committed, so a builder release inside the allowed range is only noticed when CI runs. --- .github/dependabot.yml | 13 +- .github/workflows/build.yaml | 5 + CLAUDE.md | 4 +- composer.json | 1 + src/ValueObject/Fb.php | 11 +- src/ValueObject/Fbc.php | 2 +- src/ValueObject/Fbp.php | 2 +- tests/ValueObject/FbTest.php | 3 +- tests/ValueObject/FbcTest.php | 34 +++ tests/ValueObject/FbpTest.php | 34 +++ .../ParamBuilderConformanceTest.php | 223 ++++++++++++++++++ 11 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 tests/ValueObject/ParamBuilderConformanceTest.php diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5f5a57a..52d9fc6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,15 @@ # Dependabot only reads this file from the default branch, 2.x, which is also the only branch it watches. # -# Only facebook/php-business-sdk is watched. The Graph API version the client talks to is ApiConfig::APIVersion from that -# package, and Meta ships a new major of it for every Graph API version, a few times a year. Without a PR that widens the -# constraint, users of this library stay capped at the previous Graph API version. +# Two packages are watched: +# +# - facebook/php-business-sdk: the Graph API version the client talks to is ApiConfig::APIVersion from that package, and +# Meta ships a new major of it for every Graph API version, a few times a year. Without a PR that widens the +# constraint, users of this library stay capped at the previous Graph API version. +# - facebook/capi-param-builder-php: a dev dependency used as a test oracle for the _fbc/_fbp cookie format, see +# tests/ValueObject/ParamBuilderConformanceTest.php. A new major should be tested against as soon as it exists. # # No composer.lock is committed, so a PR is only opened when a release falls outside the allowed range, i.e. for a new -# major. The other dependencies are left out on purpose: their ranges already float, and the dev tools are held on the +# major. Releases inside the range are picked up by the weekly scheduled run of the build workflow instead. The other dependencies are left out on purpose: their ranges already float, and the dev tools are held on the # majors that still support PHP 8.1. version: 2 @@ -16,6 +20,7 @@ updates: interval: "weekly" allow: - dependency-name: "facebook/php-business-sdk" + - dependency-name: "facebook/capi-param-builder-php" versioning-strategy: "widen" labels: - "dependencies" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index eeeb652..fd4ed37 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,6 +6,11 @@ on: - "2.x" pull_request: ~ workflow_dispatch: ~ + schedule: + # No composer.lock is committed, so every run installs the newest allowed dependencies. Running weekly means a + # release that breaks something, e.g. a change to the cookie format in facebook/capi-param-builder-php, is + # noticed even when nobody pushes + - cron: "0 5 * * 1" jobs: coding-standards: diff --git a/CLAUDE.md b/CLAUDE.md index cda4349..1a4ff08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,4 +62,6 @@ So to add a field: add the public property, map it in `getMapping()`, and regist **Value objects (`src/ValueObject/`)** — `Fbc`/`Fbp` (extending `Fb`) model the `_fbc`/`_fbp` cookie values with `fromString()` validation and `value()` serialization; assignable to `User::$fbc`/`$fbp` as either the typed object or a raw string. Both accept the optional trailing appendix segment that Meta's parameter builder writes (`getAppendix()`/`withAppendix()`) and write it back unchanged, so a cookie value round-trips byte for byte. -The `facebook/php-business-sdk` dependency is used only for `Normalizer`, `Util::hash`, and `ApiConfig::APIVersion` (the API version is pinned to whatever that package ships). Dependabot (`.github/dependabot.yml`) watches this one package on `2.x` and opens a PR that widens the constraint when Meta releases a new major; `1.x` is not watched. CI covers the normalization and hashing on the new major; run `LiveClientTest` once against it before tagging a release. +The `facebook/php-business-sdk` dependency is used only for `Normalizer`, `Util::hash`, and `ApiConfig::APIVersion` (the API version is pinned to whatever that package ships). Dependabot (`.github/dependabot.yml`) watches this package and `facebook/capi-param-builder-php` on `2.x` and opens a PR that widens the constraint when Meta releases a new major; `1.x` is not watched. CI covers the normalization and hashing on the new major; run `LiveClientTest` once against it before tagging a release. + +`facebook/capi-param-builder-php` is a **dev** dependency used as a test oracle, never in `src/`. `tests/ValueObject/ParamBuilderConformanceTest.php` asserts that every `_fbc`/`_fbp` value Meta's own builder writes is parsed by `Fbc`/`Fbp` and written back byte for byte. The check is deliberately one-directional: the builder's own parser is only structural (it accepts `a.b.c.d`), so the value objects stay stricter, and they are more lenient about the two-character appendix, where the builder only accepts six language tokens. No `composer.lock` is committed, so a builder release inside the allowed range is only noticed when CI runs; the build workflow therefore also runs on a weekly schedule. diff --git a/composer.json b/composer.json index e10bcf7..d22c8a2 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ }, "require-dev": { "ergebnis/composer-normalize": "^2.50", + "facebook/capi-param-builder-php": "^1.3.1", "infection/infection": "^0.29", "jangregor/phpstan-prophecy": "^2.3", "nyholm/psr7": "^1.8", diff --git a/src/ValueObject/Fb.php b/src/ValueObject/Fb.php index ab99bfe..3554782 100644 --- a/src/ValueObject/Fb.php +++ b/src/ValueObject/Fb.php @@ -9,6 +9,11 @@ abstract class Fb { + /** + * The subdomain index is the number of dots in the domain the cookie is set on. These are the three values Meta + * documents, but a cookie on a deeper domain has a higher index: Meta's own parameter builder writes fb.3. for + * a.b.example.co.uk + */ public const SUBDOMAIN_INDEX_COM = 0; public const SUBDOMAIN_INDEX_FACEBOOK_COM = 1; @@ -60,11 +65,7 @@ public function getSubdomainIndex(): int */ public function withSubdomainIndex(int $subdomainIndex): self { - Assert::oneOf($subdomainIndex, [ - self::SUBDOMAIN_INDEX_COM, - self::SUBDOMAIN_INDEX_FACEBOOK_COM, - self::SUBDOMAIN_INDEX_WWW_FACEBOOK_COM, - ]); + Assert::greaterThanEq($subdomainIndex, 0); $obj = clone $this; $obj->subdomainIndex = $subdomainIndex; diff --git a/src/ValueObject/Fbc.php b/src/ValueObject/Fbc.php index 7b5ad3b..4fdc6fe 100644 --- a/src/ValueObject/Fbc.php +++ b/src/ValueObject/Fbc.php @@ -18,7 +18,7 @@ final class Fbc extends Fb * - fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA * - fb.1.1788781160733.IwAR1a-b_c.AQECAQMB */ - private const REGEXP_FBC = '/^fb\.([012])\.(\d{13})\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]{2,8}))?$/'; + private const REGEXP_FBC = '/^fb\.(0|[1-9]\d{0,2})\.(\d{13})\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]{2,8}))?$/'; private string $clickId; diff --git a/src/ValueObject/Fbp.php b/src/ValueObject/Fbp.php index e5a1ede..341d724 100644 --- a/src/ValueObject/Fbp.php +++ b/src/ValueObject/Fbp.php @@ -25,7 +25,7 @@ public static function fromString(string $value): self { // Must match something like this: fb.1.1656874832584.1088522659 or fb.1.1656874832584.1088522659.AQEAAQMB // NOTICE we match for 13 digits for the creation time. That number will be 14 digits in year 2286, so I guess it's safe to test for a specific number of digits ;) - if (preg_match('/^fb\.([012])\.(\d{13})\.(\d+)(?:\.([A-Za-z0-9_-]{2,8}))?$/', $value, $matches) !== 1) { + if (preg_match('/^fb\.(0|[1-9]\d{0,2})\.(\d{13})\.(\d+)(?:\.([A-Za-z0-9_-]{2,8}))?$/', $value, $matches) !== 1) { throw new InvalidArgumentException(sprintf('The value "%s" didn\'t match the expected pattern for fbp', $value)); } diff --git a/tests/ValueObject/FbTest.php b/tests/ValueObject/FbTest.php index 307d9ed..1a10b80 100644 --- a/tests/ValueObject/FbTest.php +++ b/tests/ValueObject/FbTest.php @@ -50,6 +50,7 @@ public static function subdomainIndexes(): \Generator yield 'com' => [Fb::SUBDOMAIN_INDEX_COM]; yield 'facebook.com' => [Fb::SUBDOMAIN_INDEX_FACEBOOK_COM]; yield 'www.facebook.com' => [Fb::SUBDOMAIN_INDEX_WWW_FACEBOOK_COM]; + yield 'a deeper domain' => [3]; } /** @@ -70,7 +71,7 @@ public function it_rejects_an_invalid_subdomain_index(int $subdomainIndex): void public static function invalidSubdomainIndexes(): \Generator { yield [-1]; - yield [3]; + yield [\PHP_INT_MIN]; } /** diff --git a/tests/ValueObject/FbcTest.php b/tests/ValueObject/FbcTest.php index fd7f057..83fa1e8 100644 --- a/tests/ValueObject/FbcTest.php +++ b/tests/ValueObject/FbcTest.php @@ -139,4 +139,38 @@ public function it_keeps_the_appendix_through_the_immutable_setters(): void self::assertSame('AQECAQMB', $fbc->withClickId('Other')->getAppendix()); self::assertSame('fb.2.1657051589577.IwAR1a-b_c.AQECAQMB', $fbc->withSubdomainIndex(2)->value()); } + + /** + * @test + */ + public function it_accepts_a_subdomain_index_above_two(): void + { + $fbc = Fbc::fromString('fb.3.1657051589577.ClickId'); + + self::assertSame(3, $fbc->getSubdomainIndex()); + self::assertSame('fb.3.1657051589577.ClickId', $fbc->value()); + } + + /** + * @test + * + * @dataProvider invalidSubdomainIndexes + */ + public function it_rejects_an_invalid_subdomain_index(string $subdomainIndex): void + { + $this->expectException(InvalidArgumentException::class); + + Fbc::fromString(sprintf('fb.%s.1657051589577.ClickId', $subdomainIndex)); + } + + /** + * @return \Generator + */ + public static function invalidSubdomainIndexes(): \Generator + { + yield 'a leading zero, which could not be written back unchanged' => ['01']; + yield 'negative' => ['-1']; + yield 'more digits than a domain can have dots' => ['1000']; + yield 'not a number' => ['x']; + } } diff --git a/tests/ValueObject/FbpTest.php b/tests/ValueObject/FbpTest.php index c0f4038..b992fbe 100644 --- a/tests/ValueObject/FbpTest.php +++ b/tests/ValueObject/FbpTest.php @@ -101,4 +101,38 @@ public function it_keeps_the_appendix_through_the_immutable_setters(): void self::assertSame('fb.1.1656874832584.123123.AQEAAQMB', $fbp->withRandomNumber(123123)->value()); } + + /** + * @test + */ + public function it_accepts_a_subdomain_index_above_two(): void + { + $fbp = Fbp::fromString('fb.3.1656874832584.1088522659'); + + self::assertSame(3, $fbp->getSubdomainIndex()); + self::assertSame('fb.3.1656874832584.1088522659', $fbp->value()); + } + + /** + * @test + * + * @dataProvider invalidSubdomainIndexes + */ + public function it_rejects_an_invalid_subdomain_index(string $subdomainIndex): void + { + $this->expectException(InvalidArgumentException::class); + + Fbp::fromString(sprintf('fb.%s.1656874832584.1088522659', $subdomainIndex)); + } + + /** + * @return \Generator + */ + public static function invalidSubdomainIndexes(): \Generator + { + yield 'a leading zero, which could not be written back unchanged' => ['01']; + yield 'negative' => ['-1']; + yield 'more digits than a domain can have dots' => ['1000']; + yield 'not a number' => ['x']; + } } diff --git a/tests/ValueObject/ParamBuilderConformanceTest.php b/tests/ValueObject/ParamBuilderConformanceTest.php new file mode 100644 index 0000000..04f5f26 --- /dev/null +++ b/tests/ValueObject/ParamBuilderConformanceTest.php @@ -0,0 +1,223 @@ + $clickId], []); + $after = (int) ceil(microtime(true) * 1000); + + $fbc = Fbc::fromString($value); + + self::assertSame($value, $fbc->value()); + self::assertSame($clickId, $fbc->getClickId()); + self::assertSame(1, $fbc->getSubdomainIndex()); + self::assertGreaterThanOrEqual($before, $fbc->getCreationTime()); + self::assertLessThanOrEqual($after, $fbc->getCreationTime()); + self::assertNotNull($fbc->getAppendix()); + } + + /** + * @return \Generator + */ + public static function clickIds(): \Generator + { + yield 'letters and digits' => ['IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA']; + yield 'base64url with a dash and an underscore' => ['IwZXh0bgNhZW0CMTAAAR-uK_5w']; + yield 'ending in an underscore' => ['IwY2xjawMxabc123_']; + yield 'short' => ['abc']; + } + + /** + * @test + */ + public function it_parses_the_fbc_the_builder_writes_when_the_fbclid_changes(): void + { + $value = self::fbc('www.example.com', ['fbclid' => 'NewClickId'], ['_fbc' => 'fb.1.1657051589577.OldClickId.AQECAQMB']); + + $fbc = Fbc::fromString($value); + + self::assertSame($value, $fbc->value()); + self::assertSame('NewClickId', $fbc->getClickId()); + self::assertGreaterThan(1657051589577, $fbc->getCreationTime()); + } + + /** + * @test + * + * @dataProvider hosts + * + * @param list|null $domains + */ + public function it_parses_the_fbp_the_builder_generates(string $host, ?array $domains, int $expectedSubdomainIndex): void + { + // the builder generates a random number of varying length, so a few rounds exercise more than one shape + for ($i = 0; $i < 20; ++$i) { + $value = self::fbp($host, [], [], $domains); + + $fbp = Fbp::fromString($value); + + self::assertSame($value, $fbp->value()); + self::assertSame($expectedSubdomainIndex, $fbp->getSubdomainIndex()); + self::assertSame(explode('.', $value)[3], (string) $fbp->getRandomNumber()); + self::assertNotNull($fbp->getAppendix()); + } + } + + /** + * @test + * + * @dataProvider hosts + * + * @param list|null $domains + */ + public function it_agrees_with_the_builder_on_the_subdomain_index_of_an_fbc(string $host, ?array $domains, int $expectedSubdomainIndex): void + { + $value = self::fbc($host, ['fbclid' => 'ClickId'], [], $domains); + + $fbc = Fbc::fromString($value); + + self::assertSame($value, $fbc->value()); + self::assertSame($expectedSubdomainIndex, $fbc->getSubdomainIndex()); + } + + /** + * @return \Generator|null, int}> + */ + public static function hosts(): \Generator + { + yield 'a registrable domain' => ['example.com', null, 1]; + yield 'a subdomain' => ['www.example.com', null, 1]; + yield 'a host with a port' => ['www.example.com:8443', null, 1]; + yield 'a two-label public suffix, given the list of domains' => ['shop.example.co.uk', ['example.co.uk'], 2]; + yield 'a deep host without a list of domains' => ['a.b.example.co.uk', null, 3]; + yield 'a host without dots' => ['localhost', null, 0]; + yield 'an ip address' => ['127.0.0.1', null, 0]; + } + + /** + * @test + */ + public function it_parses_the_legacy_cookies_the_builder_upgrades_with_an_appendix(): void + { + $cookies = ['_fbc' => 'fb.1.1657051589577.ClickId', '_fbp' => 'fb.1.1656874832584.1088522659']; + + $fbcValue = self::fbc('www.example.com', [], $cookies); + $fbpValue = self::fbp('www.example.com', [], $cookies); + + self::assertStringStartsWith('fb.1.1657051589577.ClickId.', $fbcValue); + self::assertStringStartsWith('fb.1.1656874832584.1088522659.', $fbpValue); + + $fbc = Fbc::fromString($fbcValue); + self::assertSame($fbcValue, $fbc->value()); + self::assertSame(1657051589577, $fbc->getCreationTime()); + self::assertSame('ClickId', $fbc->getClickId()); + self::assertSame(substr($fbcValue, (int) strrpos($fbcValue, '.') + 1), $fbc->getAppendix()); + + $fbp = Fbp::fromString($fbpValue); + self::assertSame($fbpValue, $fbp->value()); + self::assertSame(1656874832584, $fbp->getCreationTime()); + self::assertSame(1088522659, $fbp->getRandomNumber()); + self::assertSame(substr($fbpValue, (int) strrpos($fbpValue, '.') + 1), $fbp->getAppendix()); + } + + /** + * @test + * + * @dataProvider appendixes + */ + public function it_parses_the_cookies_the_builder_passes_through_unchanged(string $appendix): void + { + $cookies = [ + '_fbc' => sprintf('fb.1.1657051589577.ClickId.%s', $appendix), + '_fbp' => sprintf('fb.1.1656874832584.1088522659.%s', $appendix), + ]; + + // the builder accepts them as they are ... + self::assertSame($cookies['_fbc'], self::fbc('www.example.com', [], $cookies)); + self::assertSame($cookies['_fbp'], self::fbp('www.example.com', [], $cookies)); + + // ... and so do we + self::assertSame($cookies['_fbc'], Fbc::fromString($cookies['_fbc'])->value()); + self::assertSame($appendix, Fbc::fromString($cookies['_fbc'])->getAppendix()); + self::assertSame($cookies['_fbp'], Fbp::fromString($cookies['_fbp'])->value()); + self::assertSame($appendix, Fbp::fromString($cookies['_fbp'])->getAppendix()); + } + + /** + * @return \Generator + */ + public static function appendixes(): \Generator + { + yield 'eight characters: a new value' => ['AQECAQMB']; + yield 'eight characters: an unchanged value' => ['AQEAAQMB']; + yield 'eight characters: a modified value' => ['AQEDAQMB']; + + // the two-character language tokens written by earlier versions of Meta's builders + foreach (['AQ', 'Ag', 'Aw', 'BA', 'BQ', 'Bg'] as $languageToken) { + yield sprintf('the language token %s', $languageToken) => [$languageToken]; + } + } + + /** + * @param array $query + * @param array $cookies + * @param list|null $domains + */ + private static function fbc(string $host, array $query, array $cookies, ?array $domains = null): string + { + $value = self::process($host, $query, $cookies, $domains)->getFbc(); + self::assertIsString($value); + + return $value; + } + + /** + * @param array $query + * @param array $cookies + * @param list|null $domains + */ + private static function fbp(string $host, array $query, array $cookies, ?array $domains = null): string + { + $value = self::process($host, $query, $cookies, $domains)->getFbp(); + self::assertIsString($value); + + return $value; + } + + /** + * @param array $query + * @param array $cookies + * @param list|null $domains + */ + private static function process(string $host, array $query, array $cookies, ?array $domains): ParamBuilder + { + $paramBuilder = new ParamBuilder($domains); + $paramBuilder->processRequest($host, $query, $cookies); + + return $paramBuilder; + } +} From 15a7dda486f82e771d1dd2181388b5790d3b41d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 21 Sep 2026 13:02:45 +0200 Subject: [PATCH 2/2] Drop the weekly scheduled CI run --- .github/dependabot.yml | 2 +- .github/workflows/build.yaml | 5 ----- CLAUDE.md | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 52d9fc6..9552a0e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,7 +9,7 @@ # tests/ValueObject/ParamBuilderConformanceTest.php. A new major should be tested against as soon as it exists. # # No composer.lock is committed, so a PR is only opened when a release falls outside the allowed range, i.e. for a new -# major. Releases inside the range are picked up by the weekly scheduled run of the build workflow instead. The other dependencies are left out on purpose: their ranges already float, and the dev tools are held on the +# major. Releases inside the range produce no PR; the highest jobs install them the next time CI runs. The other dependencies are left out on purpose: their ranges already float, and the dev tools are held on the # majors that still support PHP 8.1. version: 2 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index fd4ed37..eeeb652 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,11 +6,6 @@ on: - "2.x" pull_request: ~ workflow_dispatch: ~ - schedule: - # No composer.lock is committed, so every run installs the newest allowed dependencies. Running weekly means a - # release that breaks something, e.g. a change to the cookie format in facebook/capi-param-builder-php, is - # noticed even when nobody pushes - - cron: "0 5 * * 1" jobs: coding-standards: diff --git a/CLAUDE.md b/CLAUDE.md index 1a4ff08..72db8d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,4 +64,4 @@ So to add a field: add the public property, map it in `getMapping()`, and regist The `facebook/php-business-sdk` dependency is used only for `Normalizer`, `Util::hash`, and `ApiConfig::APIVersion` (the API version is pinned to whatever that package ships). Dependabot (`.github/dependabot.yml`) watches this package and `facebook/capi-param-builder-php` on `2.x` and opens a PR that widens the constraint when Meta releases a new major; `1.x` is not watched. CI covers the normalization and hashing on the new major; run `LiveClientTest` once against it before tagging a release. -`facebook/capi-param-builder-php` is a **dev** dependency used as a test oracle, never in `src/`. `tests/ValueObject/ParamBuilderConformanceTest.php` asserts that every `_fbc`/`_fbp` value Meta's own builder writes is parsed by `Fbc`/`Fbp` and written back byte for byte. The check is deliberately one-directional: the builder's own parser is only structural (it accepts `a.b.c.d`), so the value objects stay stricter, and they are more lenient about the two-character appendix, where the builder only accepts six language tokens. No `composer.lock` is committed, so a builder release inside the allowed range is only noticed when CI runs; the build workflow therefore also runs on a weekly schedule. +`facebook/capi-param-builder-php` is a **dev** dependency used as a test oracle, never in `src/`. `tests/ValueObject/ParamBuilderConformanceTest.php` asserts that every `_fbc`/`_fbp` value Meta's own builder writes is parsed by `Fbc`/`Fbp` and written back byte for byte. The check is deliberately one-directional: the builder's own parser is only structural (it accepts `a.b.c.d`), so the value objects stay stricter, and they are more lenient about the two-character appendix, where the builder only accepts six language tokens. No `composer.lock` is committed, so a builder release inside the allowed range produces no Dependabot PR; the `highest` CI jobs install it the next time CI runs.