Blog · Advertiser CI
An advertiser conversion worker should fail in CI on the clock, not in Test Events.
The merge that ships Date.now() into Meta event_time is a green pull request if the only check is that the vendor returned 200 in a laptop test. A measurement engineer at an advertiser owns the worker that posts Purchase. They do not own the GTM container, and they do not own Ads Manager. They own the JSON that leaves the process.
Meta event_time is Unix seconds, at most 10 digits, and the event has to be within the last 7 days. Date.now() is 13 digits. Read as seconds, that integer is tens of thousands of years out. The request errors. Test Events stays empty. The pixel on the thank-you page can still look fine, because the pixel does not send event_time. The engineer who waits for the marketer to notice an empty Test Events tab is debugging production with a screenshot.
The install is the fixture next to the worker, checked on every pull request. The pixellint repository ships a composite GitHub Action: uses aleksUIX/pixellint at a v* tag, with path set to the fixture file and kind set to json for a CAPI body or url for a pixel. Pin the tag you intend to run. cargo install pixellint and npm install pixellint are the same binary. The playground at pixellint.org runs that engine too. CI is how the body is gated. The playground is how a fired URL that never lived in the repo gets the same check.
Exit 1 is an error, and a warning is still exit 0
The CLI exits 0 when no finding has error severity. Warnings do not fail the job. It exits 1 when any error is present. It exits 2 when the invocation is wrong: a bad kind, a missing file, a rulepack that does not load. A red build is not a linter style nit. It is the class of miss that drops the event or sends a raw email.
Put the golden Purchase in fixtures, with example.com and a documented hash, never a live HAR and never an access token. Include the cases that must fail as separate fixtures you expect to exit 1: 13-digit event_time, a raw email in user_data.em, a hashed client IP, website without event_source_url, Purchase without value and currency. A suite that only has the happy path will stay green on the bug you already shipped once.
kind url reads a pixel or postback URL. kind json reads a CAPI or Measurement Protocol body. state template tells the checker the macros are still unexpanded, so a CACHEBUSTER token is not a privacy finding. vendor is the caller claim. The action passes those through to pixellint validate. A step that curls graph.facebook.com and asserts HTTP 200 does not do this. The 200 arrives for a body the model will not train on.
The step that fails the clock
Pin a v* release. kind json for the worker body. Exit 1 when an error finding is present. Warnings alone stay exit 0.
- uses: aleksUIX/pixellint@v0.31.10
with:
path: fixtures/meta-capi.json
kind: json
# same binary, same exit
pixellint validate json @fixtures/meta-capi.json --rulepack vendor/meta-conversions-api
That fixture has to be the body the worker emits, not a sample copied from the vendor docs in 2023. If the worker divides by 1000 at the edge and the fixture is still 13 digits, CI is testing a file nobody posts. Generate the fixture from the same function the worker calls, with a frozen clock in the test, and check that output.
Meta dedup needs the same event_id on the server and eventID on the pixel. CI can require the fixture to contain event_id. It cannot see whether GTM minted a second UUID. That second check is a fired URL or a copied fbq call, pasted once into the playground, not a weekly HAR. The engineer owns the fixture. The agency or the marketer owns the container. The contract is still one id.
What the red job will not see
CI does not publish the GTM container. A trigger that fires Purchase on the landing page, or a test_event_code left on the production worker, is a behavior the fixture can encode only if someone put that mistake in the fixture. Strip test_event_code in the production code path and keep a fixture that fails when the field is present, if production must not send it.
The playground stores artifacts you paste, under the site privacy policy. CI does not. Do not point the action at a file that contains a real buyer email or a live token. example.com and a sample digest are the fixture. A golden body that leaked a real address is a git history problem, not a linter victory.
Pixellint is not affiliated with Meta. Passing the action means the artifact matches the pack. It does not mean Events Manager attributed the order, and it does not mean the pixel on the page used the same event id. The measurement engineer at an advertiser can make the worker honest. They still need one look at the browser hit, on the playground or in Network, after the container is published.
Pin the release the action downloads
The composite action does not compile pixellint from source. It downloads a release tarball for the runner: Linux or macOS, x86_64 or aarch64. A Windows runner is outside that install step. version auto uses the action's own v* tag when you pin uses aleksUIX/pixellint@v0.31.10. If the ref is a branch, auto falls through to latest.
Latest means Monday's rule change can fail Tuesday's deploy of a worker you did not edit. Pin the tag. Bump it in a pull request when you want the new findings. extra-args is how you pass --rulepack vendor/meta-conversions-api when the file could be read as more than one body. The vendor input is a different flag: it records the vendor you claim, and it does not by itself limit the pack.
npm install pixellint and cargo install pixellint put the same binary on a developer laptop. The action is the path that does not depend on a laptop. A measurement engineer at an advertiser who only runs the playground before merge is back to a manual gate. The fixture in the pull request is the gate that survives a Friday deploy.
The token stays out of the fixture, and the clock stays put on retry
The Graph pixel id is in the request path. The access token is a query parameter. Neither belongs in the JSON fixture, and neither belongs in the pull request diff. The fixture is the data array: event_name, event_time, event_id, action_source, user_data, custom_data. test_event_code diverts the row to Test Events while the token and the pixel id stay the production ones. A fixture that still contains test_event_code will look successful in the test tab and absent from the dataset you are trying to fill.
On a timeout, post the same body again. Refreshing event_time to now moves an event that was already inside the 7 day window, and a later clock does not repair a body Meta already rejected for a different field. Minting a new event_id on that retry creates a second Purchase once the first copy lands. Meta keeps the first event_name plus event_id on that pixel id for about 48 hours and discards the later copy. The first arrival keeps its value. A CI fixture can lock the id and the clock to constants so a refactor that calls randomUUID or Date.now inside the serializer goes red.
The same worker often posts a second destination. GA4 Measurement Protocol wants timestamp_micros in microseconds. Data Manager wants eventTimestamp as an RFC 3339 string, and one plain-text email fails the whole request with HTTP 400, so every destination on that call misses. One fixture per destination. A Meta-only golden file will stay green while the Google body hashes the address the way Meta does and Google strips Gmail dots first. Those are different digests. The engineer who maintains the worker is the person who can keep both fixtures next to both serializers.
What that engineer can gate on Monday
- Pin aleksUIX/pixellint at a v* tag, or install the same binary. Do not float latest on the worker repo.
- kind json on the CAPI fixture. kind url on the pixel fixture. state template only when macros are still unexpanded.
- Fail the job on exit 1. Treat exit 0 with warnings as a reading assignment, not as a pass you ignore forever.
- Keep a fixture that is the 13-digit clock, and one that is a raw email, so those exits stay red.
- Generate the golden body from the worker function. A hand-copied sample from the docs is a different program.
- Paste one published pixel URL into the playground after release. CI never saw that request.
Sources
Contract pages
The dated argument is above. These pages are the field lists.