A tag ships. A partner's player drops it. Two hours later someone finds <Duration>00:30</Duration> instead of 00:00:30, or a <MediaFile> on http:// in an HTTPS app. Both are visible in the XML. Neither is visible in a screenshot of the creative.
The gap is between "QA looked at it" and "the file left the repo." That is a linter job. VAST just never had one that was cheap enough to run on every pull request.
vastlint is an open source VAST linter: 228 rules across VAST 2.0 through 4.4, plus SIMID, OMID, VMAP, and DAAST. The core is Rust. You do not need a Rust toolchain in CI. The GitHub Action downloads a static binary and runs vastlint check.
The workflow
# .github/workflows/vast-lint.yml
name: Validate VAST tags
on:
push:
paths:
- "tags/**/*.xml"
pull_request:
paths:
- "tags/**/*.xml"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aleksUIX/vastlint-action@v1
with:
path: tags/**/*.xml
Any PR that touches a tag under tags/ now runs the catalog. Errors fail the job (exit code 1). Warnings and infos do not, unless you ask them to.
The action installs nothing from crates.io. It pulls vastlint-linux-x86_64.tar.gz (or the macOS / aarch64 build on those runners) from the GitHub releases of the linter, so the job does not compile 228 rules from source on every run.
Pin the linter version if you want the catalog frozen:
- uses: aleksUIX/vastlint-action@v1
with:
path: tags/**/*.xml
version: v0.13.2
version: latest is the default. Pinning is the right call once a pipeline is in production; a new rule that fires on existing tags should be a deliberate bump, not a surprise red X.
What a failure looks like
tag.xml VAST 4.2
error <Duration> value does not match required format HH:MM:SS
or HH:MM:SS.mmm
VAST-2.0-duration-format
/VAST/Ad[0]/InLine/Creatives/Creative[0]/Linear/Duration
error <MediaFile> URL uses HTTP on a document that will be loaded
over HTTPS
VAST-2.0-mediafile-https
/VAST/Ad[0]/InLine/Creatives/Creative[0]/Linear/MediaFiles/MediaFile[0]
info <MediaFiles> has no <Mezzanine>
VAST-4.1-mezzanine-recommended
✖ 2 errors, 0 warnings, 1 info
The Duration one is the classic. 00:30 is what a templating system emits when someone thinks the field is mm:ss. Strict players reject it. Lenient ones mis-fire quartile beacons. The HTTPS one is mixed content: the ad simply does not play.
Both have stable rule IDs. You can grep a log for VAST-2.0-duration-format and land on the docs page with the spec citation and a fix.
Fail on the warnings that cost money
Some rules are warnings because the document is still schema-legal, and still a problem. Missing <Mezzanine> on a 4.1 tag is the SSAI one: stitching platforms reject it, browsers do not care. VPAID apiFramework on a 4.1+ tag is deprecated and CTV will skip it.
Rules with a direct revenue impact are marked $ in the catalog. To treat warnings as failures:
- uses: aleksUIX/vastlint-action@v1
with:
path: tags/**/*.xml
fail-on-warning: true
That is --fail-on-warning on the CLI. Use it once the error-level noise is gone, not on day one, or the first run will look like the tool is broken rather than like the tags are.
Tune per rule
Not every shop serves CTV. Mezzanine is noise for browser-only inventory and load-bearing for Roku. Drop a vastlint.toml at the repo root:
[rules]
"VAST-4.1-mezzanine-recommended" = "error"
"VAST-2.0-mediafile-https" = "error"
"VAST-4.1-vpaid-apiframework" = "error"
"VAST-4.1-verification-vendor-format" = "off"
Valid levels: error, warning, info, off. vastlint init writes a starter file with all 228 rules commented out at their defaults. The CLI walks up from the working directory and uses the first file it finds.
JSON if you want to annotate the PR
- uses: aleksUIX/vastlint-action@v1
with:
path: tags/**/*.xml
format: json
Each file becomes one JSON object (NDJSON if you pass several):
{
"file": "tags/preroll.xml",
"version": "4.2",
"valid": false,
"summary": { "errors": 1, "warnings": 0, "infos": 0 },
"issues": [
{
"id": "VAST-2.0-inline-adsystem",
"severity": "error",
"message": "<InLine> must contain <AdSystem>",
"path": "/VAST/Ad[0]/InLine",
"spec_ref": "IAB VAST 2.0 §2.2.1"
}
]
}
Pipe that into whatever review bot you already have. The path is an XPath with a line and column in the human output, so a comment can land on the element that is wrong rather than on line 1 of a 400-line wrapper.
Local, Docker, not only Actions
The same binary runs locally:
$ cargo install vastlint
$ vastlint check tags/**/*.xml
Or without a Rust toolchain:
$ docker run --rm -v "$(pwd)/tags":/data aleksuix/vastlint check /data/*.xml
The image is FROM scratch, under 5 MB. Cold start is under 10 ms. That is the same catalog as the Action, so a tag that fails locally fails in CI, and the reverse.
Paste a single tag into the browser validator if you do not want a clone. The tag tester fetches a live URL and follows the wrapper chain, which is the right tool once the file in the repo is clean and the partner's wrapper is the remaining suspect.
What this does not catch
A linter reads the document you have. It cannot tell you the wrapper three hops downstream will set allowMultipleAds="false" and collapse a pod, or that the media file 200s in your office and 404s on Roku. For those you still need a fetch. vastlint check https://… will follow <VASTAdTagURI> chains from the CLI if you point it at a live tag; most CI jobs should keep validating the files in the repo and treat live-chain inspection as a separate scheduled job, so a partner outage does not fail every PR.
It also cannot tell you the creative is ugly. That was never the job.
What it does catch is the class of defect that has been surviving visual QA for twenty years because nobody diffed the XML: wrong Duration format, HTTP media on HTTPS inventory, missing AdSystem, VPAID on a CTV tag, a SIMID file with type="application/javascript". Those are one-line fixes. They should never reach a partner.
Top comments (0)