Automating Campaign Link Validation in CI/CD Pipelines

Campaign link validation in CI/CD catches broken URLs, missing Open Graph tags, and redirect failures before they reach production. The pattern is simple: install mlz in your pipeline, run a check against every campaign destination URL in your config, and fail the build if anything is wrong. Here's the complete setup for GitHub Actions and GitLab CI.

CI/CD pipeline diagram showing stages: git push, Build, Validate Links (mlz preflight highlighted), Tests, Deploy

Why campaign links belong in your CI pipeline

Marketing teams and engineering teams share infrastructure more often than they think. Landing pages are deployed by engineers. Campaign configs referencing destination URLs often live in repositories. When a landing page deploy breaks a campaign's destination URL, the engineering team finds out from the marketing team hours or days later — after the damage is done.

The fix is to treat campaign link validation the same way you treat other integration tests: run it in CI, fail the build on failures, and require a green check before anything ships. This is what campaign link validation looks like as a first-class engineering practice.

The operational cost is low. mlz is an npm package that installs in seconds. Each validation check takes 1–3 seconds. A pipeline step that validates 10 campaign links adds roughly 30 seconds to a build. That's a reasonable price for catching a broken og:image or a redirect that strips UTM parameters before it ships to production.

GitHub Actions: validate every campaign link

This workflow runs on every push to main and on pull requests. It installs mlz, then validates a set of campaign destination URLs. If any check fails, the build fails and the PR cannot be merged.

.github/workflows/campaign-link-check.yml
name: Campaign Link Validation

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  validate-campaign-links:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install MissingLinkz
        run: npm install -g missinglinkz

      - name: Validate campaign destination URLs
        env:
          MLZ_API_KEY: ${{ secrets.MLZ_API_KEY }}
        run: |
          mlz check "https://yoursite.com/landing-spring" --format json \
            | jq -e '.valid == true'
          mlz check "https://yoursite.com/landing-q2" --format json \
            | jq -e '.valid == true'
          mlz inspect "https://yoursite.com/landing-spring" --format json \
            | jq -e '.success == true'

A few things to note about this setup:

  • mlz check validates URL resolution, SSL, redirects, and response time. It exits 0 on success and 1 on failure — the pipeline handles exit codes automatically.
  • mlz inspect checks Open Graph tags, Twitter Cards, viewport, canonical URL, and favicon. Run it against any landing page that will be shared on social media.
  • The jq -e flag makes jq return a non-zero exit code when the filter evaluates to false, so the step fails if valid or success is false.
  • MLZ_API_KEY is optional for basic validation. Add it as a GitHub Actions secret if you want results stored in the MissingLinkz dashboard.

Validating links from a config file

Hardcoding each URL in the workflow file doesn't scale. A better pattern is to keep your campaign destination URLs in a JSON config file and loop over them in the pipeline step. Here's an example with a campaigns.json config and a shell loop:

campaigns.json
{
  "campaigns": [
    {
      "name": "spring-launch",
      "destination": "https://yoursite.com/landing-spring"
    },
    {
      "name": "q2-product",
      "destination": "https://yoursite.com/landing-q2"
    }
  ]
}
validate-links.sh
#!/bin/bash
set -e

FAILED=0

for url in $(jq -r '.campaigns[].destination' campaigns.json); do
  echo "Checking: $url"

  result=$(mlz check "$url" --format json)
  valid=$(echo "$result" | jq -r '.valid')

  if [ "$valid" != "true" ]; then
    echo "FAIL: $url failed validation"
    echo "$result" | jq '.checks[] | select(.status != "pass")'
    FAILED=$((FAILED + 1))
  else
    echo "PASS: $url"
  fi
done

if [ "$FAILED" -gt 0 ]; then
  echo "$FAILED campaign link(s) failed validation. Blocking deploy."
  exit 1
fi

echo "All campaign links passed validation."

This script collects failures across all URLs before exiting. This is preferable to failing fast because it gives you a complete picture of what's broken in a single CI run, rather than making you re-run the pipeline after fixing each failure one at a time.

GitLab CI example

The same pattern works in GitLab CI. Add a validate-links job to your .gitlab-ci.yml that runs before the deploy stage:

.gitlab-ci.yml
stages:
  - build
  - validate
  - test
  - deploy

validate-campaign-links:
  stage: validate
  image: node:20
  script:
    - npm install -g missinglinkz
    - bash validate-links.sh
  variables:
    MLZ_API_KEY: $MLZ_API_KEY
  only:
    - main
    - merge_requests

The validate stage runs after build but before test and deploy. A campaign link failure blocks the entire pipeline. Set MLZ_API_KEY as a GitLab CI/CD variable in your project settings.

Allowing warnings, blocking failures

Not every check should be a hard block. A warn on response time (page is slow but not critically so) might be acceptable for a staging environment but worth addressing before production. A fail on SSL or destination resolution should always block the deploy.

The mlz check and mlz inspect commands return structured JSON where each check has a status of "pass", "warn", or "fail". You can parse these to implement a tiered blocking policy:

tiered-check.sh
result=$(mlz check "$URL" --format json)

# Hard failures — always block
failures=$(echo "$result" | jq '[.checks[] | select(.status == "fail")] | length')

# Warnings — log but don't block
warnings=$(echo "$result" | jq '[.checks[] | select(.status == "warn")] | length')

if [ "$warnings" -gt 0 ]; then
  echo "WARNING: $warnings advisory check(s) — review before production"
  echo "$result" | jq '.checks[] | select(.status == "warn")'
fi

if [ "$failures" -gt 0 ]; then
  echo "FAIL: $failures check(s) failed. Blocking deploy."
  echo "$result" | jq '.checks[] | select(.status == "fail")'
  exit 1
fi

This pattern lets you surface warnings in CI logs without blocking the build, while hard failures (404, SSL error, completely missing OG tags) still prevent a broken deploy.

Caching tips for faster pipeline runs

The main cost in a campaign link validation step is network latency: each check makes an HTTP request to the destination URL. For destination URLs that haven't changed, you can skip re-checking them by caching results between pipeline runs.

In GitHub Actions, cache the mlz global install between runs:

GitHub Actions npm cache step
- name: Cache npm global
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-global-mlz-${{ hashFiles('**/package-lock.json') }}

The mlz install itself is fast (under 5 seconds), so caching is optional. More impactful: scope your validation step to run only when files that reference campaign URLs change, using GitHub Actions' paths filter:

paths filter in workflow
on:
  push:
    paths:
      - 'campaigns.json'
      - 'src/campaigns/**'
      - 'content/**'

This prevents the validation job from running on every commit that touches unrelated files, reducing pipeline minutes and keeping CI fast for teams making frequent non-campaign changes.

For the full guide on what validation covers and why each check matters, see campaign link validation: the complete guide. For building UTM links programmatically as part of the same pipeline, see how to build UTM links programmatically.

FAQ

Does mlz need an API key to run in CI?
No. mlz check and mlz inspect work without an API key for basic URL validation and OG tag inspection. An API key is only required if you want check results stored in the MissingLinkz dashboard for tracking over time. For CI use, set MLZ_API_KEY as a secret if you want storage; omit it if you only need the exit code and JSON output.
What happens when mlz check finds a failure?
The command exits with a non-zero status code, which causes the CI step to fail. It also outputs structured JSON with a checks array where each failed check has a status: "fail" and a human-readable message field. You can parse this JSON to extract specific failure details for your pipeline logs.
Can I validate OG tags in CI before a deploy?
Yes, with one caveat: mlz inspect checks the live URL. If your landing page hasn't deployed yet, the check will run against the current production version. For pre-deploy checks against a staging environment, point the validation at your staging URL before the production deploy step.
How do I handle links that are intentionally redirected?
mlz check reports redirects as a warn (single hop) or fail (multiple hops) depending on the chain length. If your campaign links are expected to redirect (e.g., through a link shortener or vanity URL), parse the JSON output and filter for the specific checks you care about rather than using a blanket pass/fail check.
Should I validate every campaign link on every push?
For small teams with a handful of campaign links, yes — validation is fast enough that blanket checking makes sense. For larger teams with dozens of campaign configs, use the paths filter to run validation only when campaign-related files change, or run a nightly scheduled job instead of per-push validation.

Add it to your next CI run

Install MissingLinkz and add a campaign link validation step to your pipeline. Catches broken destinations, missing OG tags, and redirect failures before they ship.

npm install -g missinglinkz
mlz check "https://yoursite.com/landing" --format json | jq -e '.valid == true'

Exits 0 on pass, 1 on failure — works in any CI system. See the docs for full options.