Skip to content

release: v0.8.1: dependency and security maintenance #107

release: v0.8.1: dependency and security maintenance

release: v0.8.1: dependency and security maintenance #107

Workflow file for this run

name: Release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch:
inputs:
publish_chrome:
description: "Publish Chrome extension to Web Store"
type: boolean
default: false
required: false
env:
CARGO_TERM_COLOR: always
# Workflow-level default: every job gets read-only access unless it explicitly
# declares broader permissions. Jobs that publish or attest override below.
permissions:
contents: read
jobs:
# ── Smoke tests ─────────────────────────────────────────────────────────────
# All publish / release jobs declare `needs: smoke-test` so no artefact ever
# leaves the repo unless every module passes validation.
#
# Covers:
# 1. vastlint-core + vastlint-ffi — cargo test --all (unit + integration)
# 2. vastlint-cli — build release binary; valid fixture
# exits 0; invalid fixture exits non-0;
# specific error/warning/info rule IDs
# asserted via --format json; --vast-version
# override (up and down); --ignore-pattern
# macro suppression; fix subcommand;
# daemon OTP protocol (Python struct.pack)
# 3. vastlint-mcp — build release binary, drive initialize +
# tools/list via stdin, assert 7 tools
# returned with correct names; actual
# tools/call for validate_vast (valid +
# invalid), fix_vast, list_rules,
# explain_rule, inspect_vast
# 4. vastlint-wasm — wasm-pack build (nodejs target) then
# a Node.js script that imports the
# package and validates the same fixtures;
# specific rule IDs asserted; warning
# fixture checked for valid=true
smoke-test:
name: Smoke test all modules
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # v (pinned SHA)
with:
toolchain: stable
targets: wasm32-unknown-unknown
components: clippy
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
# ── 1. Unit + integration tests (core, ffi, cli) ──────────────────────
- name: cargo test --all
run: cargo test --all
# ── 2. CLI smoke test ─────────────────────────────────────────────────
- name: Build CLI (release)
run: cargo build --release -p vastlint-cli
- name: CLI smoke — valid fixture exits 0
run: |
./target/release/vastlint check \
crates/vastlint-core/tests/fixtures/valid_4.2.xml
echo "Exit code: $?"
- name: CLI smoke — invalid fixture exits 1
run: |
set +e
./target/release/vastlint check \
crates/vastlint-core/tests/fixtures/err_no_ad.xml
CODE=$?
set -e
if [ "$CODE" -eq 0 ]; then
echo "ERROR: vastlint exited 0 on an invalid fixture — expected non-zero"
exit 1
fi
echo "Exit code $CODE (expected non-zero) ✓"
# ── 3. MCP smoke test ─────────────────────────────────────────────────
- name: Build MCP server (release)
run: cargo build --release -p vastlint-mcp
- name: MCP smoke — initialize + tools/list returns 5 tools
run: |
RESPONSE=$(
(
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1.0"}}}'
sleep 0.2
echo '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
sleep 0.2
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
sleep 1
) | ./target/release/vastlint-mcp 2>/dev/null
)
echo "$RESPONSE" | grep -q '"tools"' || {
echo "FAIL: initialize response missing tools capability"
echo "$RESPONSE"
exit 1
}
echo "capabilities.tools present ✓"
TOOL_COUNT=$(echo "$RESPONSE" | grep '"tools":\[' | grep -o '"name":"' | wc -l | tr -d ' ')
if [ "$TOOL_COUNT" -ne 7 ]; then
echo "FAIL: expected 7 tools, got $TOOL_COUNT"
echo "$RESPONSE"
exit 1
fi
echo "tools/list returned $TOOL_COUNT tools ✓"
for TOOL in validate_vast validate_vast_url fix_vast list_rules explain_rule inspect_vast; do
echo "$RESPONSE" | grep -q "\"$TOOL\"" || {
echo "FAIL: tool '$TOOL' missing from tools/list"
echo "$RESPONSE"
exit 1
}
echo " $TOOL ✓"
done
# ── 4. WASM smoke test ────────────────────────────────────────────────
- name: Install wasm-pack
run: cargo install wasm-pack --locked
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
- name: Build WASM (nodejs target)
run: wasm-pack build crates/vastlint-wasm --target nodejs --out-dir ../../npm/pkg-node
- name: Build WASM (bundler target)
run: wasm-pack build crates/vastlint-wasm --target bundler --out-dir ../../npm/pkg
- name: Assemble npm package
run: node npm/scripts/assemble.js
- name: WASM smoke — valid fixture returns no errors
run: |
node - <<'EOF'
const { validate } = require('./npm');
const fs = require('fs');
const validXml = fs.readFileSync(
'crates/vastlint-core/tests/fixtures/valid_4.2.xml', 'utf8'
);
const result = validate(validXml);
if (result.summary.errors !== 0) {
console.error('WASM smoke FAILED: expected 0 errors on valid fixture, got', result.summary.errors);
process.exit(1);
}
if (!result.summary.valid) {
console.error('WASM smoke FAILED: expected valid=true on valid fixture');
process.exit(1);
}
console.log('WASM valid fixture ✓ (errors=0, valid=true)');
EOF
- name: WASM smoke — invalid fixture returns errors
run: |
node - <<'EOF'
const { validate } = require('./npm');
const fs = require('fs');
const invalidXml = fs.readFileSync(
'crates/vastlint-core/tests/fixtures/err_no_ad.xml', 'utf8'
);
const result = validate(invalidXml);
if (result.summary.errors === 0) {
console.error('WASM smoke FAILED: expected errors > 0 on invalid fixture');
process.exit(1);
}
if (result.summary.valid) {
console.error('WASM smoke FAILED: expected valid=false on invalid fixture');
process.exit(1);
}
console.log('WASM invalid fixture ✓ (errors=' + result.summary.errors + ', valid=false)');
EOF
# ── 2b. CLI smoke — specific rule IDs ────────────────────────────────
# Pick a representative spread across rule families and VAST versions to
# catch regressions in the core rule engine that cargo test would not catch
# at the binary level (e.g. a broken --format json serialisation).
- name: CLI smoke — error rule IDs (JSON output)
run: |
FIXTURES=(
"err_inline_missing_impression.xml:VAST-2.0-inline-impression"
"err_missing_adsystem.xml:VAST-2.0-inline-adsystem"
"err_no_version.xml:VAST-2.0-root-version"
"err_missing_duration.xml:VAST-2.0-linear-duration"
"err_mediafile_missing_attrs.xml:VAST-2.0-mediafile-delivery"
"err_wrapper_missing_vasttag.xml:VAST-2.0-wrapper-vastadtaguri"
"err_adservingid_missing.xml:VAST-4.1-adservingid-present"
"err_universaladid_missing.xml:VAST-4.0-universaladid-present"
"err_verification_vendor.xml:VAST-4.1-verification-vendor"
"err_companion_clicktracking_no_id.xml:VAST-4.0-companion-clicktracking-id"
"err_icon_missing_resource.xml:VAST-3.0-icon-resource"
"err_progress_no_offset.xml:VAST-3.0-progress-offset"
)
PASS=0; FAIL=0
for ENTRY in "${FIXTURES[@]}"; do
FILE="${ENTRY%%:*}"
RULE="${ENTRY##*:}"
FIXTURE="crates/vastlint-core/tests/fixtures/$FILE"
OUTPUT=$(./target/release/vastlint check "$FIXTURE" --format json 2>/dev/null || true)
if echo "$OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if any(i['id']=='$RULE' for i in d['issues']) else 1)"; then
echo "PASS [$FILE → $RULE]"
PASS=$((PASS+1))
else
echo "FAIL [$FILE → $RULE not found in issues]"
echo " Output: $OUTPUT" | head -5
FAIL=$((FAIL+1))
fi
done
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
- name: CLI smoke — warning and info rule IDs (JSON output)
run: |
FIXTURES=(
"warn_vpaid.xml:VAST-4.1-vpaid-apiframework"
"warn_duplicate_impression.xml:VAST-2.0-duplicate-impression"
"warn_version_mismatch.xml:VAST-2.0-version-mismatch"
"warn_flash_mediafile.xml:VAST-2.0-flash-mediafile"
"warn_survey_deprecated.xml:VAST-4.1-survey-deprecated"
"warn_url_invalid.xml:VAST-2.0-url-invalid"
"warn_bitrate_conflict.xml:VAST-3.0-bitrate-conflict"
"warn_companion_renderingmode.xml:VAST-4.1-companion-renderingmode-value"
"warn_tracking_event_unknown.xml:VAST-4.1-tracking-event-value"
"warn_adtype_invalid.xml:VAST-4.1-adtype-value"
"warn_blockedadcategories_no_authority.xml:VAST-4.1-blockedadcategories-no-authority"
"warn_universaladid_idvalue_removed.xml:VAST-4.1-universaladid-idvalue-removed"
"info_http_tracking.xml:VAST-2.0-tracking-https"
)
PASS=0; FAIL=0
for ENTRY in "${FIXTURES[@]}"; do
FILE="${ENTRY%%:*}"
RULE="${ENTRY##*:}"
FIXTURE="crates/vastlint-core/tests/fixtures/$FILE"
OUTPUT=$(./target/release/vastlint check "$FIXTURE" --format json 2>/dev/null || true)
if echo "$OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if any(i['id']=='$RULE' for i in d['issues']) else 1)"; then
echo "PASS [$FILE → $RULE]"
PASS=$((PASS+1))
else
echo "FAIL [$FILE → $RULE not found in issues]"
FAIL=$((FAIL+1))
fi
done
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
# ── 2c. CLI smoke — --vast-version override ───────────────────────────
- name: CLI smoke — --vast-version upgrade fires 4.1 rules on 2.0 doc
run: |
# A plain VAST 2.0 document forced to 4.1 must:
# • report version "4.1" in JSON
# • fire VAST-4.1-adservingid-present (missing on 2.0 doc)
cat > /tmp/v2_simple.xml <<'XMLEOF'
<VAST version="2.0"><Ad id="1"><InLine>
<AdSystem>Test</AdSystem><AdTitle>Test Ad</AdTitle>
<Impression><![CDATA[https://t.example.com/imp]]></Impression>
<Creatives><Creative><Linear>
<Duration>00:00:30</Duration>
<MediaFiles><MediaFile type="video/mp4" width="640" height="480" bitrate="500" delivery="progressive">
<![CDATA[https://cdn.example.com/ad.mp4]]>
</MediaFile></MediaFiles>
</Linear></Creative></Creatives>
</InLine></Ad></VAST>
XMLEOF
OUTPUT=$(./target/release/vastlint check /tmp/v2_simple.xml --vast-version 4.1 --format json 2>/dev/null || true)
VERSION=$(echo "$OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])")
if [ "$VERSION" != "4.1" ]; then
echo "FAIL: expected version=4.1 in JSON, got '$VERSION'"
exit 1
fi
echo "PASS: version field is 4.1 ✓"
echo "$OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if any(i['id']=='VAST-4.1-adservingid-present' for i in d['issues']) else 1)" || {
echo "FAIL: VAST-4.1-adservingid-present did not fire after --vast-version 4.1 override"
exit 1
}
echo "PASS: VAST-4.1-adservingid-present fires on forced 4.1 ✓"
- name: CLI smoke — --vast-version downgrade suppresses 4.x rules
run: |
# A VAST 4.1 document forced to 2.0 must NOT fire any 4.x-only rules.
OUTPUT=$(./target/release/vastlint check \
crates/vastlint-core/tests/fixtures/valid_4.1.xml \
--vast-version 2.0 --format json 2>/dev/null || true)
HITS=$(echo "$OUTPUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
bad = [i['id'] for i in d['issues'] if i['id'].startswith('VAST-4.')]
print(len(bad), bad)
")
COUNT=$(echo "$HITS" | awk '{print $1}')
if [ "$COUNT" -ne 0 ]; then
echo "FAIL: 4.x rules fired after --vast-version 2.0 downgrade: $HITS"
exit 1
fi
echo "PASS: no 4.x rules fired after downgrade ✓"
- name: CLI smoke — --vast-version invalid value exits non-zero
run: |
set +e
./target/release/vastlint check \
crates/vastlint-core/tests/fixtures/valid_4.2.xml \
--vast-version 9.9 >/dev/null 2>&1
CODE=$?
set -e
if [ "$CODE" -eq 0 ]; then
echo "FAIL: invalid --vast-version 9.9 should exit non-zero, got 0"
exit 1
fi
echo "PASS: --vast-version 9.9 exits $CODE (non-zero) ✓"
# ── 2d. CLI smoke — --ignore-pattern ─────────────────────────────────
- name: CLI smoke — --ignore-pattern suppresses macro URL errors
run: |
# A VAST with macro placeholders in URL fields fires url-empty/url-invalid
# without the flag. With --ignore-pattern the macros are replaced before
# validation and no URL errors should appear.
cat > /tmp/vmacro.xml <<'XMLEOF'
<VAST version="4.2"><Ad id="1"><InLine>
<AdSystem>Acme DSP</AdSystem>
<AdServingId>srv-macro-01</AdServingId>
<AdTitle>Macro Test Ad</AdTitle>
<Impression><![CDATA[${IMP_URL}]]></Impression>
<Creatives><Creative>
<UniversalAdId idRegistry="Ad-ID">mac01</UniversalAdId>
<Linear>
<Duration>00:00:30</Duration>
<TrackingEvents>
<Tracking event="start"><![CDATA[%%TRACKER_START%%]]></Tracking>
<Tracking event="firstQuartile"><![CDATA[${Q1_URL}]]></Tracking>
<Tracking event="midpoint"><![CDATA[${MID_URL}]]></Tracking>
<Tracking event="thirdQuartile"><![CDATA[${Q3_URL}]]></Tracking>
<Tracking event="complete"><![CDATA[${COMP_URL}]]></Tracking>
</TrackingEvents>
<MediaFiles><MediaFile type="video/mp4" width="1920" height="1080" bitrate="2000" delivery="progressive">
<![CDATA[https://cdn.example.com/hd.mp4]]>
</MediaFile></MediaFiles>
</Linear>
</Creative></Creatives>
</InLine></Ad></VAST>
XMLEOF
# Without flag: url-empty must fire
URL_ISSUES=$(./target/release/vastlint check /tmp/vmacro.xml --format json 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['issues'] if 'url' in i['id'].lower()])" || true)
if [ "$URL_ISSUES" = "[]" ]; then
echo "FAIL: expected URL errors without --ignore-pattern, got none"
exit 1
fi
echo "PASS: URL errors present without flag: $URL_ISSUES ✓"
# With flag: no URL errors
OUTPUT=$(./target/release/vastlint check /tmp/vmacro.xml \
--ignore-pattern '\$\{[^}]+\}|%%[^%]+%%' \
--format json 2>/dev/null || true)
URL_ISSUES=$(echo "$OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print([i['id'] for i in d['issues'] if 'url' in i['id'].lower()])" || true)
if [ "$URL_ISSUES" != "[]" ]; then
echo "FAIL: expected no URL errors with --ignore-pattern, got: $URL_ISSUES"
exit 1
fi
echo "PASS: no URL errors after --ignore-pattern replacement ✓"
- name: CLI smoke — --ignore-pattern invalid regex exits non-zero
run: |
set +e
./target/release/vastlint check \
crates/vastlint-core/tests/fixtures/valid_4.2.xml \
--ignore-pattern '[invalid' >/dev/null 2>&1
CODE=$?
set -e
if [ "$CODE" -eq 0 ]; then
echo "FAIL: invalid --ignore-pattern should exit non-zero, got 0"
exit 1
fi
echo "PASS: invalid --ignore-pattern exits $CODE (non-zero) ✓"
# ── 2e. CLI smoke — fix subcommand ────────────────────────────────────
- name: CLI smoke — fix subcommand upgrades HTTP to HTTPS
run: |
# A fixture with HTTP URLs: fix should exit 0 and output HTTPS URLs.
cat > /tmp/http_vast.xml <<'XMLEOF'
<VAST version="2.0"><Ad id="1"><InLine>
<AdSystem>Test</AdSystem><AdTitle>Test Ad</AdTitle>
<Impression>http://t.example.com/imp</Impression>
<Creatives><Creative><Linear>
<Duration>00:00:30</Duration>
<MediaFiles><MediaFile type="video/mp4" width="640" height="480" bitrate="500" delivery="progressive">
http://cdn.example.com/ad.mp4
</MediaFile></MediaFiles>
</Linear></Creative></Creatives>
</InLine></Ad></VAST>
XMLEOF
# Use stdin mode (-) so the fixed XML is written to stdout, not in-place.
FIXED=$(./target/release/vastlint fix - < /tmp/http_vast.xml 2>/dev/null)
if [ $? -ne 0 ]; then
echo "FAIL: fix subcommand exited non-zero"
exit 1
fi
echo "PASS: fix subcommand exits 0 ✓"
if echo "$FIXED" | grep -q 'http://'; then
echo "FAIL: fixed output still contains http:// URLs"
exit 1
fi
echo "PASS: no http:// in fixed output ✓"
if ! echo "$FIXED" | grep -q 'https://'; then
echo "FAIL: fixed output has no https:// URLs"
exit 1
fi
echo "PASS: fixed output contains https:// ✓"
- name: CLI smoke — fix --dry-run exits 0 and does not modify file
run: |
cp crates/vastlint-core/tests/fixtures/valid_4.2.xml /tmp/dryrun_test.xml
./target/release/vastlint fix /tmp/dryrun_test.xml --dry-run > /dev/null 2>&1
CODE=$?
if [ "$CODE" -ne 0 ]; then
echo "FAIL: fix --dry-run exited $CODE, expected 0"
exit 1
fi
echo "PASS: fix --dry-run exits 0 ✓"
# File must be unmodified
if ! diff -q crates/vastlint-core/tests/fixtures/valid_4.2.xml /tmp/dryrun_test.xml > /dev/null; then
echo "FAIL: --dry-run modified the input file"
exit 1
fi
echo "PASS: --dry-run did not modify input file ✓"
# ── 2f. CLI smoke — daemon OTP protocol ──────────────────────────────
- name: CLI smoke — daemon OTP protocol (valid XML → valid=true)
run: |
python3 - <<'PYEOF'
import subprocess, struct, json, sys
VALID_XML = open('crates/vastlint-core/tests/fixtures/valid_4.2.xml').read()
INVALID_XML = open('crates/vastlint-core/tests/fixtures/err_no_ad.xml').read()
def daemon_call(xml_str):
proc = subprocess.Popen(
['./target/release/vastlint', 'daemon'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
)
encoded = xml_str.encode('utf-8')
proc.stdin.write(struct.pack('>I', len(encoded)) + encoded)
proc.stdin.close()
resp_len = struct.unpack('>I', proc.stdout.read(4))[0]
result = json.loads(proc.stdout.read(resp_len).decode('utf-8'))
proc.wait(timeout=5)
return result
# Valid fixture → valid=true, errors=0
r = daemon_call(VALID_XML)
assert r.get('valid') is True, f"FAIL: daemon valid fixture: expected valid=true, got {r}"
assert r.get('summary', {}).get('errors') == 0, f"FAIL: daemon valid fixture: expected errors=0, got {r}"
assert r.get('version') == '4.2', f"FAIL: daemon valid fixture: expected version=4.2, got {r.get('version')}"
print(f"PASS: daemon valid fixture → valid=true, errors=0, version=4.2 ✓")
# Invalid fixture → valid=false, errors>0
r = daemon_call(INVALID_XML)
assert r.get('valid') is False, f"FAIL: daemon invalid fixture: expected valid=false, got {r}"
assert r.get('summary', {}).get('errors', 0) > 0, f"FAIL: daemon invalid fixture: expected errors>0, got {r}"
print(f"PASS: daemon invalid fixture → valid=false, errors={r['summary']['errors']} ✓")
# Rule ID check via daemon
IMP_XML = open('crates/vastlint-core/tests/fixtures/err_inline_missing_impression.xml').read()
r = daemon_call(IMP_XML)
ids = [i['id'] for i in r.get('issues', [])]
assert 'VAST-2.0-inline-impression' in ids, f"FAIL: daemon rule ID check: VAST-2.0-inline-impression not in {ids}"
print(f"PASS: daemon rule ID VAST-2.0-inline-impression fires correctly ✓")
print("All daemon smoke tests passed.")
PYEOF
# ── 3b. MCP smoke — actual tool calls ────────────────────────────────
- name: MCP smoke — validate_vast (valid + invalid) and fix_vast tool calls
run: |
python3 - <<'PYEOF'
import subprocess, json, sys, time
VALID_XML = open('crates/vastlint-core/tests/fixtures/valid_4.2.xml').read()
INVALID_XML = open('crates/vastlint-core/tests/fixtures/err_inline_missing_impression.xml').read()
HTTP_XML = ('<VAST version="2.0"><Ad id="1"><InLine>'
'<AdSystem>T</AdSystem><AdTitle>T</AdTitle>'
'<Impression>http://t.example.com/imp</Impression>'
'<Creatives><Creative><Linear><Duration>00:00:30</Duration>'
'<MediaFiles><MediaFile delivery="progressive" type="video/mp4"'
' width="640" height="480">http://cdn.example.com/ad.mp4'
'</MediaFile></MediaFiles></Linear></Creative></Creatives>'
'</InLine></Ad></VAST>')
proc = subprocess.Popen(
['./target/release/vastlint-mcp'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
)
_id = [0]
def send(msg):
_id[0] += 1
if 'id' not in msg:
msg = dict(msg, id=_id[0])
proc.stdin.write((json.dumps(msg) + '\n').encode())
proc.stdin.flush()
return msg.get('id')
def read_until(target_id, timeout=10):
import select
deadline = time.monotonic() + timeout
buf = b''
while time.monotonic() < deadline:
r, _, _ = select.select([proc.stdout], [], [], 0.5)
if r:
chunk = proc.stdout.read1(4096)
if chunk:
buf += chunk
while b'\n' in buf:
line, buf = buf.split(b'\n', 1)
line = line.strip()
if not line:
continue
try:
d = json.loads(line)
except Exception:
continue
if d.get('id') == target_id:
return d
raise TimeoutError(f'no response for id={target_id}')
# Initialize
init_id = send({'jsonrpc':'2.0','method':'initialize','params':{
'protocolVersion':'2024-11-05','capabilities':{},'clientInfo':{'name':'ci','version':'1.0'}}})
read_until(init_id)
proc.stdin.write((json.dumps({'jsonrpc':'2.0','method':'notifications/initialized','params':{}}) + '\n').encode())
proc.stdin.flush()
# validate_vast — valid fixture
vid = send({'jsonrpc':'2.0','method':'tools/call','params':{'name':'validate_vast','arguments':{'xml': VALID_XML}}})
r = read_until(vid)
obj = json.loads(r['result']['content'][0]['text'])
assert obj['valid'] is True, f"FAIL: MCP validate_vast valid: expected valid=true, got {obj}"
assert obj['summary']['errors'] == 0, f"FAIL: MCP validate_vast valid: errors={obj['summary']['errors']}"
assert obj['version'] == '4.2', f"FAIL: MCP validate_vast valid: version={obj['version']}"
print("PASS: MCP validate_vast valid fixture ✓")
# validate_vast — invalid fixture with specific rule ID
iid = send({'jsonrpc':'2.0','method':'tools/call','params':{'name':'validate_vast','arguments':{'xml': INVALID_XML}}})
r = read_until(iid)
obj = json.loads(r['result']['content'][0]['text'])
assert obj['valid'] is False, f"FAIL: MCP validate_vast invalid: expected valid=false, got {obj}"
assert obj['summary']['errors'] > 0, f"FAIL: MCP validate_vast invalid: errors=0"
ids = [i['id'] for i in obj['issues']]
assert 'VAST-2.0-inline-impression' in ids, f"FAIL: MCP validate_vast invalid: rule ID not found in {ids}"
print(f"PASS: MCP validate_vast invalid fixture, VAST-2.0-inline-impression fires ✓")
# fix_vast — HTTP→HTTPS upgrade
fid = send({'jsonrpc':'2.0','method':'tools/call','params':{'name':'fix_vast','arguments':{'xml': HTTP_XML}}})
r = read_until(fid)
obj = json.loads(r['result']['content'][0]['text'])
assert obj['applied_count'] > 0, f"FAIL: MCP fix_vast: no fixes applied, got {obj}"
assert 'https://' in obj['xml'], f"FAIL: MCP fix_vast: no https:// in fixed XML"
assert 'http://' not in obj['xml'], f"FAIL: MCP fix_vast: http:// still in fixed XML"
print(f"PASS: MCP fix_vast applied {obj['applied_count']} fix(es), HTTP→HTTPS ✓")
proc.stdin.close()
proc.wait(timeout=5)
print("All MCP tool-call smoke tests passed.")
PYEOF
- name: MCP smoke — list_rules and explain_rule tool calls
run: |
python3 - <<'PYEOF'
import subprocess, json, time
proc = subprocess.Popen(
['./target/release/vastlint-mcp'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
)
_id = [0]
def send(msg):
_id[0] += 1
if 'id' not in msg:
msg = dict(msg, id=_id[0])
proc.stdin.write((json.dumps(msg) + '\n').encode())
proc.stdin.flush()
return msg.get('id')
def read_until(target_id, timeout=10):
import select
deadline = time.monotonic() + timeout
buf = b''
while time.monotonic() < deadline:
r, _, _ = select.select([proc.stdout], [], [], 0.5)
if r:
chunk = proc.stdout.read1(4096)
if chunk:
buf += chunk
while b'\n' in buf:
line, buf = buf.split(b'\n', 1)
line = line.strip()
if not line:
continue
try:
d = json.loads(line)
except Exception:
continue
if d.get('id') == target_id:
return d
raise TimeoutError(f'no response for id={target_id}')
init_id = send({'jsonrpc':'2.0','method':'initialize','params':{
'protocolVersion':'2024-11-05','capabilities':{},'clientInfo':{'name':'ci','version':'1.0'}}})
read_until(init_id)
proc.stdin.write((json.dumps({'jsonrpc':'2.0','method':'notifications/initialized','params':{}}) + '\n').encode())
proc.stdin.flush()
# list_rules — must return a non-empty catalog
lid = send({'jsonrpc':'2.0','method':'tools/call','params':{'name':'list_rules','arguments':{}}})
r = read_until(lid)
obj = json.loads(r['result']['content'][0]['text'])
assert obj['count'] > 0, f"FAIL: list_rules returned count=0"
assert len(obj['rules']) == obj['count'], "FAIL: list_rules count mismatch"
ids = [rule['id'] for rule in obj['rules']]
for expected_id in ['VAST-2.0-inline-impression', 'VAST-4.1-adservingid-present', 'VAST-2.0-root-version']:
assert expected_id in ids, f"FAIL: list_rules missing rule {expected_id}"
print(f"PASS: MCP list_rules returned {obj['count']} rules, key IDs present ✓")
# explain_rule — known rule returns description and hint
eid = send({'jsonrpc':'2.0','method':'tools/call','params':{
'name':'explain_rule','arguments':{'rule_id':'VAST-2.0-inline-impression'}}})
r = read_until(eid)
obj = json.loads(r['result']['content'][0]['text'])
assert obj['id'] == 'VAST-2.0-inline-impression', f"FAIL: explain_rule wrong id: {obj['id']}"
assert len(obj.get('description','')) > 10, f"FAIL: explain_rule empty description"
assert obj.get('severity') == 'error', f"FAIL: explain_rule wrong severity: {obj.get('severity')}"
print(f"PASS: MCP explain_rule VAST-2.0-inline-impression: severity=error, description present ✓")
# explain_rule — unknown rule returns graceful not-found message
uid = send({'jsonrpc':'2.0','method':'tools/call','params':{
'name':'explain_rule','arguments':{'rule_id':'VAST-9.9-nonexistent'}}})
r = read_until(uid)
obj = json.loads(r['result']['content'][0]['text'])
assert 'not found' in obj.get('description','').lower() or obj.get('severity') == 'unknown', \
f"FAIL: explain_rule unknown ID did not return graceful response: {obj}"
print("PASS: MCP explain_rule unknown rule ID → graceful not-found ✓")
proc.stdin.close()
proc.wait(timeout=5)
print("All MCP list_rules/explain_rule smoke tests passed.")
PYEOF
# ── 4b. WASM smoke — specific rule IDs ───────────────────────────────
- name: WASM smoke — specific error rule ID fires
run: |
node - <<'EOF'
const { validate } = require('./npm');
const fs = require('fs');
const FIXTURES = [
['err_inline_missing_impression.xml', 'VAST-2.0-inline-impression', true],
['err_adservingid_missing.xml', 'VAST-4.1-adservingid-present', true],
['err_missing_adsystem.xml', 'VAST-2.0-inline-adsystem', true],
];
let pass = 0, fail = 0;
for (const [file, ruleId, expectFires] of FIXTURES) {
const xml = fs.readFileSync(`crates/vastlint-core/tests/fixtures/${file}`, 'utf8');
const result = validate(xml);
const fires = result.issues.some(i => i.id === ruleId);
if (fires === expectFires) {
console.log(`PASS [${file} → ${ruleId}]`);
pass++;
} else {
console.error(`FAIL [${file} → ${ruleId}]: expected fires=${expectFires}, got ${fires}`);
fail++;
}
}
console.log(`Results: ${pass} passed, ${fail} failed`);
if (fail > 0) process.exit(1);
EOF
- name: WASM smoke — warning fixture valid=true, warnings>0
run: |
node - <<'EOF'
const { validate } = require('./npm');
const fs = require('fs');
// warn_vpaid.xml fires VAST-4.1-vpaid-apiframework (Warning) — document
// must be valid=true (no errors) but have warnings>0.
const xml = fs.readFileSync('crates/vastlint-core/tests/fixtures/warn_vpaid.xml', 'utf8');
const result = validate(xml);
if (!result.summary.valid) {
console.error('FAIL: warn_vpaid.xml should be valid=true (warnings are not errors)');
process.exit(1);
}
console.log('PASS: warn_vpaid.xml valid=true ✓');
if (result.summary.warnings === 0) {
console.error('FAIL: warn_vpaid.xml should have warnings>0');
process.exit(1);
}
console.log(`PASS: warn_vpaid.xml warnings=${result.summary.warnings} ✓`);
const fires = result.issues.some(i => i.id === 'VAST-4.1-vpaid-apiframework');
if (!fires) {
console.error('FAIL: VAST-4.1-vpaid-apiframework should fire on warn_vpaid.xml');
console.error('Issues:', result.issues.map(i => i.id));
process.exit(1);
}
console.log('PASS: VAST-4.1-vpaid-apiframework fires on warn_vpaid.xml ✓');
EOF
- name: WASM smoke — info fixture valid=true, errors=0
run: |
node - <<'EOF'
const { validate } = require('./npm');
const fs = require('fs');
// info_http_tracking.xml fires VAST-2.0-tracking-https at Info level.
// Document must be valid=true and errors=0.
const xml = fs.readFileSync('crates/vastlint-core/tests/fixtures/info_http_tracking.xml', 'utf8');
const result = validate(xml);
if (!result.summary.valid) {
console.error('FAIL: info_http_tracking.xml should be valid=true');
process.exit(1);
}
if (result.summary.errors !== 0) {
console.error('FAIL: info_http_tracking.xml should have errors=0, got', result.summary.errors);
process.exit(1);
}
const fires = result.issues.some(i => i.id === 'VAST-2.0-tracking-https');
if (!fires) {
console.error('FAIL: VAST-2.0-tracking-https should fire on info_http_tracking.xml');
process.exit(1);
}
console.log(`PASS: info_http_tracking.xml valid=true, errors=0, VAST-2.0-tracking-https fires ✓`);
EOF
# ── 5. CLI smoke — default text format ───────────────────────────────
- name: CLI smoke — default text format contains rule ID and severity
run: |
# Default output (no --format flag) must print human-readable text
# with at least the rule ID and "error" for an error-level fixture.
OUTPUT=$(./target/release/vastlint check \
crates/vastlint-core/tests/fixtures/err_no_ad.xml 2>/dev/null || true)
if ! echo "$OUTPUT" | grep -qi 'VAST-2\.0-ad-required\|no-ad\|ad_required\|err_no_ad'; then
echo "FAIL: text output missing expected rule reference"
echo "Output was: $OUTPUT"
exit 1
fi
if ! echo "$OUTPUT" | grep -qi 'error'; then
echo "FAIL: text output missing 'error' keyword"
echo "Output was: $OUTPUT"
exit 1
fi
echo "PASS: default text format contains rule ID and severity ✓"
# ── 6. VS Code extension unit tests ──────────────────────────────────
- name: VS Code extension — unit tests (utils.ts pure functions)
working-directory: vscode
run: |
npm ci
npm test
# ── WASM build (single canonical build) ─────────────────────────────────────
# Builds the npm package once from the attested source tree and uploads it as
# a workflow artifact. publish-npm and publish-vscode both download this
# artifact instead of rebuilding — ensuring every consumer publishes the same
# bytes that were validated by smoke-test.
#
# Building independently in each publish job means:
# - Different jobs may produce different binaries (non-reproducible Rust/WASM)
# - The published npm package and the VSIX may embed different WASM blobs
# - Neither matches what was tested in smoke-test
# - The SLSA provenance covers the release binaries but not what npm/vscode ship
build-wasm:
name: Build WASM npm package
runs-on: ubuntu-latest
needs: smoke-test
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # v (pinned SHA)
with:
toolchain: stable
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Install wasm-pack
run: cargo install wasm-pack --locked
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
- name: Build WASM (bundler target — ESM)
run: wasm-pack build crates/vastlint-wasm --target bundler --out-dir ../../npm/pkg
- name: Build WASM (nodejs target — CJS)
run: wasm-pack build crates/vastlint-wasm --target nodejs --out-dir ../../npm/pkg-node
- name: Assemble npm package
run: node npm/scripts/assemble.js
- name: Upload assembled npm package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wasm-npm-package
path: npm/
# Retain for the full workflow run so publish jobs can download it.
retention-days: 1
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
needs: smoke-test
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
binary: vastlint
crate: vastlint-cli
archive: vastlint-linux-x86_64.tar.gz
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
binary: vastlint
crate: vastlint-cli
archive: vastlint-linux-aarch64.tar.gz
- target: x86_64-apple-darwin
os: macos-latest
binary: vastlint
crate: vastlint-cli
archive: vastlint-macos-x86_64.tar.gz
- target: aarch64-apple-darwin
os: macos-latest
binary: vastlint
crate: vastlint-cli
archive: vastlint-macos-aarch64.tar.gz
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
binary: vastlint-mcp
crate: vastlint-mcp
archive: vastlint-mcp-linux-x86_64.tar.gz
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
binary: vastlint-mcp
crate: vastlint-mcp
archive: vastlint-mcp-linux-aarch64.tar.gz
- target: x86_64-apple-darwin
os: macos-latest
binary: vastlint-mcp
crate: vastlint-mcp
archive: vastlint-mcp-macos-x86_64.tar.gz
- target: aarch64-apple-darwin
os: macos-latest
binary: vastlint-mcp
crate: vastlint-mcp
archive: vastlint-mcp-macos-aarch64.tar.gz
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # v (pinned SHA)
with:
toolchain: stable
targets: ${{ matrix.target }}
# Stamp every crate's Cargo.toml with the release version so that
# env!("CARGO_PKG_VERSION") inside the binary matches the git tag.
- name: Bump crate versions to match git tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
for toml in crates/*/Cargo.toml; do
# crate's own [package] version
sed -i.bak -E "s/^version = \"[^\"]*\"/version = \"${VERSION}\"/" "$toml"
# inter-crate path-dependency version pins, e.g. cli/mcp/wasm pin
# vastlint-core = { path = "...", version = "X" }; path-only deps untouched
sed -i.bak -E "s/(vastlint-[a-z-]+ = \{[^}]*version = \")[^\"]*(\")/\1${VERSION}\2/g" "$toml"
rm "$toml.bak"
done
echo "Bumped all crates to ${VERSION}"
- name: Install musl tools (Linux)
if: contains(matrix.target, 'musl')
run: |
sudo apt-get update -q
sudo apt-get install -y musl-tools
# aarch64-unknown-linux-musl needs a cross linker (aarch64-linux-musl-gcc).
# `cross` is unreliable here: on the runner it silently fails to read
# workspace metadata and falls back to host cargo, which lacks the musl
# cross compiler, so the build dies in ring's build script. setup-cross-
# toolchain-action installs the musl cross toolchain and wires up the
# linker/CC env so a plain `cargo build` cross-compiles cleanly.
- name: Set up cross toolchain (Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-musl'
uses: taiki-e/setup-cross-toolchain-action@3d9770ce98eb7dbcf378563182a5e8031165f75b # v1.41.0
with:
target: ${{ matrix.target }}
- name: Build (cargo)
run: cargo build --release --target ${{ matrix.target }} -p ${{ matrix.crate }}
- name: Package
run: |
BINARY=target/${{ matrix.target }}/release/${{ matrix.binary }}
tar czf ${{ matrix.archive }} -C $(dirname $BINARY) $(basename $BINARY)
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.archive }}
path: ${{ matrix.archive }}
# ── C FFI library build ─────────────────────────────────────────────────────
# Builds vastlint-ffi as both a shared library (.so/.dylib) and a static
# archive (.a) for each supported platform. The output tarballs contain:
# vastlint.h — C header (ABI contract, same across all platforms)
# libvastlint.a — static library for link-time embedding (Go CGo, etc.)
# libvastlint.so — shared library for runtime linking (Linux)
# libvastlint.dylib — shared library for runtime linking (macOS)
#
# These artifacts are the foundation for Go, Ruby, Python, Java, and Erlang
# language bindings.
build-ffi:
name: Build FFI ${{ matrix.target }}
runs-on: ${{ matrix.os }}
needs: smoke-test
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
static: libvastlint.a
shared: libvastlint.so
archive: vastlint-ffi-linux-x86_64.tar.gz
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
static: libvastlint.a
shared: libvastlint.so
archive: vastlint-ffi-linux-aarch64.tar.gz
- target: x86_64-apple-darwin
os: macos-latest
static: libvastlint.a
shared: libvastlint.dylib
archive: vastlint-ffi-macos-x86_64.tar.gz
- target: aarch64-apple-darwin
os: macos-latest
static: libvastlint.a
shared: libvastlint.dylib
archive: vastlint-ffi-macos-aarch64.tar.gz
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # v (pinned SHA)
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Install cross (Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: cargo install cross --locked
- name: Build FFI crate (cross — Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: cross build --release --target ${{ matrix.target }} -p vastlint-ffi
- name: Build FFI crate (cargo — all others)
if: matrix.target != 'aarch64-unknown-linux-gnu'
run: cargo build --release --target ${{ matrix.target }} -p vastlint-ffi
- name: Package FFI artifacts
run: |
OUTDIR=target/${{ matrix.target }}/release
STAGING=ffi-staging
mkdir -p "$STAGING"
# Header — same for all platforms, included in every tarball so
# consumers get a self-contained package.
cp crates/vastlint-ffi/vastlint.h "$STAGING/"
# Static library — Rust outputs libvastlint_ffi.a (crate name with
# underscores). Rename to libvastlint.a so consumers use a clean name.
cp "$OUTDIR/libvastlint_ffi.a" "$STAGING/libvastlint.a"
# Shared library — same rename convention.
# matrix.shared is "libvastlint.so" or "libvastlint.dylib"; derive
# the extension using bash parameter expansion (not Actions expression).
SHARED="${{ matrix.shared }}"
EXT="${SHARED##*.}"
cp "$OUTDIR/libvastlint_ffi.${EXT}" "$STAGING/${SHARED}"
tar czf ${{ matrix.archive }} -C "$STAGING" .
- name: Upload FFI artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.archive }}
path: ${{ matrix.archive }}
# ── Build precompiled NIFs ───────────────────────────────────────────────────
# Builds the vastlint_nif cdylib for the four tier-1 BEAM platforms.
# RustlerPrecompiled downloads these at `mix deps.get` so Elixir/Erlang users
# never need a local Rust toolchain.
#
# Output naming follows the RustlerPrecompiled convention:
# libvastlint_nif-<version>-nif-<otp>-<arch>-<os>.so / .dylib
# We ship a canonical tarball and let the checksum file pin the version.
build-nif:
name: Build NIF ${{ matrix.target }}
runs-on: ${{ matrix.os }}
needs: smoke-test
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
lib: libvastlint_nif.so
archive: vastlint-nif-linux-x86_64.tar.gz
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
lib: libvastlint_nif.so
archive: vastlint-nif-linux-aarch64.tar.gz
- target: x86_64-apple-darwin
os: macos-latest
lib: libvastlint_nif.dylib
archive: vastlint-nif-macos-x86_64.tar.gz
- target: aarch64-apple-darwin
os: macos-latest
lib: libvastlint_nif.dylib
archive: vastlint-nif-macos-aarch64.tar.gz
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # v (pinned SHA)
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Install cross (Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: cargo install cross --locked
- name: Build NIF crate (cross — Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: cross build --release --target ${{ matrix.target }} -p vastlint_nif
- name: Build NIF crate (cargo — all others)
if: matrix.target != 'aarch64-unknown-linux-gnu'
run: cargo build --release --target ${{ matrix.target }} -p vastlint_nif
- name: Package NIF artifact
run: |
OUTDIR=target/${{ matrix.target }}/release
STAGING=nif-staging
mkdir -p "$STAGING"
# Rust names the output libvastlint_nif.{so,dylib}.
# Derive extension from matrix.lib via bash parameter expansion.
LIB="${{ matrix.lib }}"
EXT="${LIB##*.}"
cp "$OUTDIR/${LIB}" "$STAGING/${LIB}"
tar czf ${{ matrix.archive }} -C "$STAGING" .
- name: Upload NIF artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.archive }}
path: ${{ matrix.archive }}
# ── Single approval gate ────────────────────────────────────────────────────
# One reviewer click unblocks every downstream publish job simultaneously.
# All publish/release jobs declare `needs: approve` instead of independently
# declaring `environment: production`, which previously triggered a separate
# approval prompt per job.
approve:
name: Approve release
needs: [smoke-test, build, build-ffi, build-nif, build-wasm]
runs-on: ubuntu-latest
environment: production
steps:
- run: echo "Release approved — proceeding with all publish jobs"
release:
name: Create GitHub Release
needs: [approve, build, build-ffi, build-nif, build-wasm]
runs-on: ubuntu-latest
permissions:
contents: write # create the GitHub Release and upload assets
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts
merge-multiple: true
- name: Extract changelog section for this release
id: changelog
run: |
# Pull the section for this tag (e.g. "## [0.3.7]") from CHANGELOG.md.
# Outputs everything between this heading and the next "## " heading.
VERSION="${GITHUB_REF_NAME#v}"
BODY=$(awk "/^## \[?${VERSION}\]?/{found=1; next} found && /^## /{exit} found{print}" CHANGELOG.md)
if [ -z "$BODY" ]; then
BODY="See [CHANGELOG.md](https://github.com/aleksUIX/vastlint/blob/main/CHANGELOG.md) for details."
fi
printf '%s\n' "$BODY" > /tmp/release-notes.md
- name: Create release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: artifacts/*
body_path: /tmp/release-notes.md
# ── SLSA Build Level 3 provenance ──────────────────────────────────────────
# Runs after the GitHub Release is created so a provenance failure never
# marks the release job itself as failed. Uses GitHub's native attestation
# action (replaces slsa-github-generator which broke on ubuntu-latest runners
# due to its external binary download step).
#
# Provenance is stored in GitHub's attestation store — verify with:
# gh attestation verify <file> --repo aleksUIX/vastlint
provenance:
name: SLSA provenance attestation
needs: [release]
runs-on: ubuntu-latest
permissions:
id-token: write # request OIDC token for Sigstore signing
attestations: write # write provenance to GitHub's attestation store
steps:
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts
merge-multiple: true
- name: Generate SLSA provenance attestation
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-path: artifacts/*
publish:
name: Publish to crates.io
needs: [approve, build]
runs-on: ubuntu-latest
# Set ENABLE_CRATES_PUBLISH to "true" in repo variables (Settings → Variables)
# to enable publishing. Without it this job is skipped entirely.
if: vars.ENABLE_CRATES_PUBLISH == 'true'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # v (pinned SHA)
with:
toolchain: stable
- name: Bump crate versions to match git tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
# Update every crate's Cargo.toml to the release version so that
# `cargo publish` always publishes the tag version, not whatever was
# last committed in Cargo.toml.
for toml in crates/*/Cargo.toml; do
# crate's own [package] version
sed -i.bak -E "s/^version = \"[^\"]*\"/version = \"${VERSION}\"/" "$toml"
# inter-crate path-dependency version pins, e.g. cli/mcp/wasm pin
# vastlint-core = { path = "...", version = "X" }; path-only deps untouched
sed -i.bak -E "s/(vastlint-[a-z-]+ = \{[^}]*version = \")[^\"]*(\")/\1${VERSION}\2/g" "$toml"
rm "$toml.bak"
done
echo "Bumped all crates to ${VERSION}"
# cargo publish refuses a dirty working tree, and the bump step above
# dirties every Cargo.toml. Commit runner-locally (never pushed) so the
# publish runs against a clean tree instead of failing with exit 101.
- name: Commit version stamp (runner-local, never pushed)
run: |
git config user.name "vastlint-release"
git config user.email "release@vastlint.org"
git commit -am "release: stamp ${GITHUB_REF_NAME#v} crate versions"
- name: Publish vastlint-core
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
# Use crates.io API to check if this exact version is already published.
# This is the only idempotency guard: any cargo publish failure below
# must fail the job. (An earlier version treated cargo exit code 101
# as "already published", but 101 is cargo's generic error code, so
# real failures were silently swallowed for four releases.)
HTTP=$(curl -s -o /dev/null -w "%{http_code}" \
"https://crates.io/api/v1/crates/vastlint-core/${VERSION}")
if [ "$HTTP" = "200" ]; then
echo "vastlint-core ${VERSION} already published, skipping"
else
cargo publish -p vastlint-core
fi
# Wait for crates.io to index the core crate before publishing the CLI.
- name: Wait for crates.io index
run: sleep 30
- name: Publish vastlint-cli
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
HTTP=$(curl -s -o /dev/null -w "%{http_code}" \
"https://crates.io/api/v1/crates/vastlint-cli/${VERSION}")
if [ "$HTTP" = "200" ]; then
echo "vastlint-cli ${VERSION} already published, skipping"
else
cargo publish -p vastlint-cli
fi
# ── Docker Hub ──────────────────────────────────────────────────────────────
# Builds a multi-arch image (amd64 + arm64) and pushes to aleksuix/vastlint.
#
# Prerequisites (one-time setup):
# 1. Create a repo named "vastlint" on hub.docker.com under aleksuix.
# 2. Create a Docker Hub access token (Account Settings → Security).
# 3. Add two secrets in this repo (Settings → Secrets → Actions):
# DOCKERHUB_USERNAME — aleksuix
# DOCKERHUB_TOKEN — the access token
#
# Set ENABLE_DOCKER_PUBLISH to "true" in repo variables to activate this job.
publish-docker:
name: Publish to Docker Hub
needs: [approve, build]
runs-on: ubuntu-latest
if: vars.ENABLE_DOCKER_PUBLISH == 'true'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# QEMU lets buildx cross-compile the arm64 layer on an amd64 runner
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Derive the plain semver version from the git tag (strips leading "v")
- name: Derive version
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
aleksuix/vastlint:latest
aleksuix/vastlint:${{ steps.version.outputs.version }}
# Use GitHub Actions cache instead of Docker Hub registry cache.
# type=registry sourced from aleksuix/vastlint:buildcache was removed —
# a compromised Docker Hub account could poison the cache layer and
# inject malicious image layers into the build without detection.
# type=gha is scoped to this repo and backed by GitHub's own cache
# service; it cannot be written to by external parties.
cache-from: type=gha
cache-to: type=gha,mode=max
publish-npm:
name: Publish to npm
needs: [approve, build, build-wasm]
runs-on: ubuntu-latest
# Set ENABLE_NPM_PUBLISH to "true" in repo variables (Settings → Variables)
# to enable publishing. Without it this job is skipped entirely.
# Prefer npm trusted publishing on GitHub Actions. If falling back to
# NPM_TOKEN, it must be a granular write token with bypass 2FA enabled.
if: vars.ENABLE_NPM_PUBLISH == 'true'
permissions:
contents: read
id-token: write # required for --provenance (links package to this CI run)
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download assembled npm package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: wasm-npm-package
path: npm
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Prepare JS release packages
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
# Stage inside GITHUB_WORKSPACE so that npm's directory-tree lookup
# finds the .npmrc written by setup-node (containing NODE_AUTH_TOKEN).
node scripts/prepare-js-release-packages.mjs "$VERSION" "$GITHUB_WORKSPACE/.release/js-packages"
- name: Publish JS packages to npm
# npm trusted publishing on GitHub-hosted runners uses the workflow's
# OIDC identity. When NPM_TOKEN is present we keep token-based auth as a
# fallback for packages that have not yet configured a trusted publisher.
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
STAGE="$GITHUB_WORKSPACE/.release/js-packages"
if [ -n "${NODE_AUTH_TOKEN:-}" ]; then
# Write auth config directly in the staged parent directory so every
# package publish runs with an explicit registry token.
printf '%s\n' \
"//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" \
'always-auth=true' \
> "$STAGE/.npmrc"
pushd "$STAGE" >/dev/null
npm whoami >/dev/null
echo "Authenticated to npm as $(npm whoami) via NPM_TOKEN"
popd >/dev/null
else
echo "NPM_TOKEN not set; relying on npm trusted publishing via OIDC"
fi
publish_if_needed() {
local package_name="$1"
local package_dir="$2"
if npm view "${package_name}@${VERSION}" version >/dev/null 2>&1; then
echo "${package_name}@${VERSION} already published — skipping"
return 0
fi
echo "Publishing ${package_name}@${VERSION} from ${package_dir}"
npm publish --provenance
}
pushd "$STAGE/vastlint" >/dev/null
publish_if_needed "vastlint" "$PWD"
popd >/dev/null
pushd "$STAGE/vastlint-client" >/dev/null
publish_if_needed "vastlint-client" "$PWD"
popd >/dev/null
pushd "$STAGE/vastlint-react" >/dev/null
publish_if_needed "vastlint-react" "$PWD"
popd >/dev/null
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# ── VS Code Marketplace ─────────────────────────────────────────────────────
# Packages and publishes the VS Code extension to the marketplace.
#
# Prerequisites (one-time setup):
# 1. Go to marketplace.visualstudio.com/manage/publishers/aleksUIX
# 2. Create a Personal Access Token at dev.azure.com with Marketplace→Manage
# scope and "All accessible organizations".
# 3. Add it as a secret in this repo (Settings → Secrets → Actions):
# VSCE_PAT — the personal access token
#
# Set ENABLE_VSCODE_PUBLISH to "true" in repo variables to activate this job.
publish-vscode:
name: Publish VS Code Extension
needs: [approve, build, build-wasm]
runs-on: ubuntu-latest
if: vars.ENABLE_VSCODE_PUBLISH == 'true'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download assembled npm package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: wasm-npm-package
path: npm
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
# Sync the extension version to match the git tag (strips leading "v")
- name: Set extension version from tag
id: ext_version
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
npm version "$VERSION" --no-git-tag-version --allow-same-version
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
working-directory: vscode
- name: Install vscode extension dependencies
run: npm ci
working-directory: vscode
# Package the extension into a .vsix regardless of whether we publish.
# This gives VS Code fork users (Cursor, VSCodium, Windsurf, etc.) a
# verified artefact built by CI and attached to the GitHub Release —
# rather than relying on a binary committed to the repo.
- name: Package VSIX
run: npx --no-install vsce package --no-update-package-json --out vastlint-${{ steps.ext_version.outputs.version }}.vsix
working-directory: vscode
- name: Upload VSIX as workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vastlint-${{ steps.ext_version.outputs.version }}.vsix
path: vscode/vastlint-${{ steps.ext_version.outputs.version }}.vsix
- name: Publish to VS Code Marketplace
run: npx --no-install vsce publish --no-update-package-json || echo "VS Code version already published, skipping"
working-directory: vscode
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
- name: Publish to Open VSX Registry
run: npx --no-install ovsx publish vastlint-${{ steps.ext_version.outputs.version }}.vsix --pat "$OVSX_TOKEN" || echo "Open VSX version already published, skipping"
working-directory: vscode
env:
OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }}
# ── MCP Registry ────────────────────────────────────────────────────────────
# Publishes vastlint-mcp to registry.modelcontextprotocol.io after the GitHub
# Release is created so the download URL in server.json is live.
#
# Authentication: GitHub OIDC (no secrets needed — just id-token: write).
# The server namespace io.github.aleksUIX/ is validated against the OIDC
# token issued for this repo, so only CI runs in aleksUIX/vastlint can publish.
#
# Prerequisites (one-time setup):
# None — OIDC is automatic. Just set ENABLE_MCP_REGISTRY_PUBLISH = "true"
# in repo variables (Settings → Variables → Actions) to activate this job.
publish-mcp-registry:
name: Publish to MCP Registry
needs: [approve, build, release]
runs-on: ubuntu-latest
if: vars.ENABLE_MCP_REGISTRY_PUBLISH == 'true'
permissions:
id-token: write # required for GitHub OIDC authentication
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Download the linux-x86_64 MCP binary — this is the canonical mcpb
# artifact published to the registry. Other platforms are available on
# the GitHub Release but the registry stores only one package entry.
- name: Download vastlint-mcp linux-x86_64 artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: vastlint-mcp-linux-x86_64.tar.gz
path: .
# Derive version and compute SHA-256 so server.json is fully populated
# before we call mcp-publisher.
- name: Patch server.json with version and fileSha256
run: |
TAG="${GITHUB_REF_NAME}" # e.g. v1.2.3
VERSION="${TAG#v}" # e.g. 1.2.3
SHA256=$(openssl dgst -sha256 -hex vastlint-mcp-linux-x86_64.tar.gz \
| awk '{print $2}')
IDENTIFIER="https://github.com/aleksUIX/vastlint/releases/download/${TAG}/vastlint-mcp-linux-x86_64.tar.gz"
jq \
--arg v "$VERSION" \
--arg sha "$SHA256" \
--arg url "$IDENTIFIER" \
'.version = $v | .packages[0].fileSha256 = $sha | .packages[0].identifier = $url' \
crates/vastlint-mcp/server.json > server.tmp
mv server.tmp crates/vastlint-mcp/server.json
echo "Patched server.json:"
cat crates/vastlint-mcp/server.json
- name: Install mcp-publisher
run: |
curl -fsSL \
"https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" \
| tar xz mcp-publisher
chmod +x mcp-publisher
mv mcp-publisher crates/vastlint-mcp/mcp-publisher
- name: Authenticate to MCP Registry (GitHub OIDC)
run: ./mcp-publisher login github-oidc
working-directory: crates/vastlint-mcp
- name: Publish to MCP Registry
working-directory: crates/vastlint-mcp
run: |
out=$(./mcp-publisher publish 2>&1) && echo "$out" && exit 0
echo "$out"
# Treat duplicate-version as success — registry already has this release.
echo "$out" | grep -q "cannot publish duplicate version" && exit 0
exit 1
# ── Publish to Smithery ─────────────────────────────────────────────────────
# Publishes vastlint-mcp to smithery.ai after the GitHub Release is created.
#
# Prerequisites (one-time setup):
# 1. Create a Smithery account at https://smithery.ai and get an API key
# from Account → API Keys.
# 2. Add the key as a secret named SMITHERY_API_KEY in this repo
# (Settings → Secrets → Actions).
#
# Set ENABLE_SMITHERY_PUBLISH = "true" in repo variables to activate this job.
publish-smithery:
name: Publish to Smithery
needs: [approve, build, release]
runs-on: ubuntu-latest
if: vars.ENABLE_SMITHERY_PUBLISH == 'true'
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
- name: Publish to Smithery
env:
SMITHERY_API_KEY: ${{ secrets.SMITHERY_API_KEY }}
run: |
# CLI syntax changed in @smithery/cli ≥ 1.x:
# publish <url> -n <name> (URL-based / remote server)
# The --config / --key flags were removed; the API key is read
# from the SMITHERY_API_KEY environment variable automatically.
out=$(npx --yes @smithery/cli@latest mcp publish \
"https://vastlint.org/mcp" \
-n "io.github.aleksUIX/vastlint" 2>&1) && echo "$out" && exit 0
echo "$out"
# Treat "Namespace not found" (one-time setup not done yet) and
# duplicate/already-published as non-fatal.
echo "$out" | grep -qiE "namespace not found|already exists|duplicate" && exit 0
exit 1
# ── Sync vastlint-go ────────────────────────────────────────────────────────
# After a successful release, push the updated prebuilt libs and header into
# the vastlint-go repo and tag it at the same version.
#
# Prerequisites (one-time setup):
# 1. Generate an SSH deploy key for vastlint-go:
# ssh-keygen -t ed25519 -f ~/VASTLINT_GO_DEPLOY_KEY -C "vastlint release bot (go)" -N ""
# 2. Add ~/VASTLINT_GO_DEPLOY_KEY.pub as a deploy key in vastlint-go
# (Settings → Deploy keys) with write access.
# 3. Add ~/VASTLINT_GO_DEPLOY_KEY (private key) as secret VASTLINT_GO_DEPLOY_KEY
# in this repo (Settings → Secrets → Actions).
#
# Set ENABLE_GO_SYNC to "true" in repo variables to activate this job.
sync-go:
name: Sync vastlint-go
needs: [build-ffi, release]
runs-on: ubuntu-latest
if: vars.ENABLE_GO_SYNC == 'true'
steps:
- name: Download all FFI artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: ffi-artifacts
# Download only the FFI tarballs by filtering on name prefix.
pattern: vastlint-ffi-*
merge-multiple: true
- name: Checkout vastlint-go via deploy key
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: aleksUIX/vastlint-go
ssh-key: ${{ secrets.VASTLINT_GO_DEPLOY_KEY }}
path: vastlint-go
- name: Unpack libs into vastlint-go
run: |
TAG="${{ github.ref_name }}"
cd vastlint-go
declare -A TARBALL_TO_DIR=(
["vastlint-ffi-macos-aarch64.tar.gz"]="libs/darwin_arm64"
["vastlint-ffi-macos-x86_64.tar.gz"]="libs/darwin_amd64"
["vastlint-ffi-linux-aarch64.tar.gz"]="libs/linux_arm64"
["vastlint-ffi-linux-x86_64.tar.gz"]="libs/linux_amd64"
)
HEADER_COPIED=0
for TARBALL in "${!TARBALL_TO_DIR[@]}"; do
DIR="${TARBALL_TO_DIR[$TARBALL]}"
SRC="../ffi-artifacts/${TARBALL}"
if [[ ! -f "$SRC" ]]; then
echo "WARNING: $SRC not found — skipping" >&2
continue
fi
mkdir -p "$DIR"
TMPDIR=$(mktemp -d)
tar xzf "$SRC" -C "$TMPDIR"
cp "$TMPDIR/libvastlint.a" "$DIR/libvastlint.a"
if [[ $HEADER_COPIED -eq 0 && -f "$TMPDIR/vastlint.h" ]]; then
cp "$TMPDIR/vastlint.h" vastlint.h
HEADER_COPIED=1
fi
rm -rf "$TMPDIR"
echo "✓ $DIR/libvastlint.a"
done
- name: Import bot GPG key
run: |
echo "${{ secrets.BOT_GPG_KEY }}" | gpg --batch --import
# Trust the key fully so git can use it without prompts
echo "AEA67F4C8B714AC26E14ACDB67DF23E270B033DB:6:" | gpg --import-ownertrust
- name: Commit and push
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
cd vastlint-go
git config user.name "vastlint release bot"
git config user.email "bot@vastlint.org"
git config user.signingkey "67DF23E270B033DB"
git config commit.gpgsign true
git add libs/ vastlint.h
git diff --cached --quiet && echo "nothing to commit" && exit 0
git commit -S -m "chore: update prebuilt libs to ${TAG}"
git push origin main
- name: Tag vastlint-go
run: |
TAG="${{ github.ref_name }}"
cd vastlint-go
# Fail loudly if the tag already exists — never silently overwrite.
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "::warning::Tag $TAG already exists on vastlint-go — skipping tag push" && exit 0
fi
git tag "$TAG"
git push origin "$TAG"
# ── Sync vastlint-python ────────────────────────────────────────────────────
# After a successful release, bump the version in the vastlint-python repo and
# tag it at the same version. The vastlint-python publish workflow then runs
# fetch-libs.sh against this release to vendor the shared libraries and pushes
# the wheel to PyPI via trusted publishing.
#
# Prerequisites (one-time setup):
# 1. Generate an SSH deploy key for vastlint-python:
# ssh-keygen -t ed25519 -f ~/VASTLINT_PYTHON_DEPLOY_KEY -C "vastlint release bot (python)" -N ""
# 2. Add ~/VASTLINT_PYTHON_DEPLOY_KEY.pub as a deploy key in vastlint-python
# (Settings → Deploy keys) with write access.
# 3. Add ~/VASTLINT_PYTHON_DEPLOY_KEY (private key) as secret
# VASTLINT_PYTHON_DEPLOY_KEY in this repo (Settings → Secrets → Actions).
#
# Set ENABLE_PYTHON_SYNC to "true" in repo variables to activate this job.
sync-python:
name: Sync vastlint-python
needs: [release]
runs-on: ubuntu-latest
if: vars.ENABLE_PYTHON_SYNC == 'true'
steps:
- name: Checkout vastlint-python via deploy key
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: aleksUIX/vastlint-python
ssh-key: ${{ secrets.VASTLINT_PYTHON_DEPLOY_KEY }}
path: vastlint-python
- name: Bump version
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
cd vastlint-python
printf '__version__ = "%s"\n' "$VERSION" > src/vastlint/_version.py
echo "set __version__ = ${VERSION}"
- name: Import bot GPG key
run: |
echo "${{ secrets.BOT_GPG_KEY }}" | gpg --batch --import
# Trust the key fully so git can use it without prompts
echo "AEA67F4C8B714AC26E14ACDB67DF23E270B033DB:6:" | gpg --import-ownertrust
- name: Commit and push
run: |
TAG="${{ github.ref_name }}"
cd vastlint-python
git config user.name "vastlint release bot"
git config user.email "bot@vastlint.org"
git config user.signingkey "67DF23E270B033DB"
git config commit.gpgsign true
git add src/vastlint/_version.py
if git diff --cached --quiet; then
echo "version already at ${TAG#v} — nothing to commit"
else
git commit -S -m "chore: release ${TAG}"
git push origin main
fi
- name: Tag vastlint-python
run: |
TAG="${{ github.ref_name }}"
cd vastlint-python
# Fail loudly if the tag already exists — never silently overwrite.
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "::warning::Tag $TAG already exists on vastlint-python — skipping tag push" && exit 0
fi
git tag "$TAG"
git push origin "$TAG"
# ── Chrome Web Store ────────────────────────────────────────────────────────
chrome-extension:
name: Publish Chrome extension
runs-on: ubuntu-latest
needs: [approve, release, build-wasm]
if: ${{ inputs.publish_chrome == true || github.event_name == 'push' }}
permissions:
contents: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download assembled npm package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: wasm-npm-package
path: npm
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Install dependencies
working-directory: chrome
run: npm ci
# Stamp the manifest version from the git tag (strips leading "v"),
# then build — so the compiled dist/manifest.json carries the right version.
- name: Set manifest version
working-directory: chrome
env:
VERSION: ${{ github.ref_name }}
run: |
node -e "
const fs = require('fs');
const version = process.env.VERSION.replace(/^v/, '');
if (!version) throw new Error('VERSION derived from git tag is empty');
const m = JSON.parse(fs.readFileSync('manifest.json','utf8'));
m.version = version;
fs.writeFileSync('manifest.json', JSON.stringify(m, null, 2) + '\n');
"
node -e "
const fs = require('fs');
const expected = process.env.VERSION.replace(/^v/, '');
const m = JSON.parse(fs.readFileSync('manifest.json','utf8'));
if (m.version !== expected) {
throw new Error(`manifest.json version mismatch: ${m.version}`);
}
"
- name: Build extension
working-directory: chrome
run: npm run build
- name: Zip dist/
working-directory: chrome
run: |
cd dist
zip -r ../vastlint-extension.zip .
- name: Upload zip to GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: chrome/vastlint-extension.zip
- name: Publish to Chrome Web Store
uses: mnao305/chrome-extension-upload@fdfe79400af990f5145a319e834aee64907ccff4 # v6.0.0
with:
file-path: chrome/vastlint-extension.zip
extension-id: ${{ secrets.CHROME_EXTENSION_ID }}
client-id: ${{ secrets.CHROME_CLIENT_ID }}
client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
publish: true
# ── Sync vastlint-erlang ─────────────────────────────────────────────────────
# After a successful release, push the updated prebuilt NIF tarballs and
# checksum file into the vastlint-erlang repo and tag it at the same version.
#
# Prerequisites (one-time setup):
# 1. Generate an SSH deploy key for vastlint-erlang:
# ssh-keygen -t ed25519 -f ~/VASTLINT_DEPLOY_KEY -C "vastlint release bot" -N ""
# 2. Add ~/VASTLINT_DEPLOY_KEY.pub as a deploy key in vastlint-erlang
# (Settings → Deploy keys) with write access.
# 3. Add ~/VASTLINT_DEPLOY_KEY (private key) as secret VASTLINT_DEPLOY_KEY
# in this repo (Settings → Secrets → Actions).
#
# Set ENABLE_ERLANG_SYNC to "true" in repo variables to activate this job.
sync-erlang:
name: Sync vastlint-erlang
needs: [build-nif, release]
runs-on: ubuntu-latest
if: vars.ENABLE_ERLANG_SYNC == 'true'
steps:
- name: Download all NIF artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: nif-artifacts
pattern: vastlint-nif-*
merge-multiple: true
- name: Checkout vastlint-erlang via deploy key
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: aleksUIX/vastlint-erlang
ssh-key: ${{ secrets.VASTLINT_DEPLOY_KEY }}
path: vastlint-erlang
- name: Update mix.exs version
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
sed -i "s/@version \"[^\"]*\"/@version \"${VERSION}\"/" \
vastlint-erlang/mix.exs
echo "Updated mix.exs to ${VERSION}"
- name: Regenerate checksum file
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
CHECKSUM_FILE="vastlint-erlang/checksum-Elixir.VastlintNif.exs"
# Write a fresh checksum map. RustlerPrecompiled resolves tarballs by
# the base_url + filename convention; keys must match exactly.
echo '%{' > "$CHECKSUM_FILE"
for TARBALL in nif-artifacts/vastlint-nif-*.tar.gz; do
BASENAME=$(basename "$TARBALL")
SHA256=$(sha256sum "$TARBALL" | awk '{print $1}')
echo " \"${BASENAME}\" => \"sha256:${SHA256}\"," >> "$CHECKSUM_FILE"
done
echo '}' >> "$CHECKSUM_FILE"
echo "Wrote $CHECKSUM_FILE"
cat "$CHECKSUM_FILE"
- name: Import bot GPG key
run: |
echo "${{ secrets.BOT_GPG_KEY }}" | gpg --batch --import
echo "AEA67F4C8B714AC26E14ACDB67DF23E270B033DB:6:" | gpg --import-ownertrust
- name: Commit and push
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
cd vastlint-erlang
git config user.name "vastlint release bot"
git config user.email "bot@vastlint.org"
git config user.signingkey "67DF23E270B033DB"
git config commit.gpgsign true
git add mix.exs checksum-Elixir.VastlintNif.exs
git diff --cached --quiet && echo "nothing to commit" && exit 0
git commit -S -m "chore: update prebuilt NIFs to ${TAG}"
git push origin main
- name: Tag vastlint-erlang
run: |
TAG="${{ github.ref_name }}"
cd vastlint-erlang
# Fail loudly if the tag already exists — never silently overwrite.
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "::warning::Tag $TAG already exists on vastlint-erlang — skipping tag push" && exit 0
fi
git tag "$TAG"
git push origin "$TAG"
# ── Sync vastlint-infra ──────────────────────────────────────────────────────
# After a successful npm publish, bump the `vastlint` npm dependency in
# vastlint-infra so the website always ships the latest WASM linter.
#
# Prerequisites (one-time setup):
# 1. Generate an SSH deploy key for vastlint-infra:
# ssh-keygen -t ed25519 -f ~/VASTLINT_INFRA_DEPLOY_KEY -C "vastlint release bot (infra)" -N ""
# 2. Add ~/VASTLINT_INFRA_DEPLOY_KEY.pub as a deploy key in vastlint-infra
# (Settings → Deploy keys) with write access.
# 3. Add ~/VASTLINT_INFRA_DEPLOY_KEY (private key) as secret VASTLINT_INFRA_DEPLOY_KEY
# in this repo (Settings → Secrets → Actions).
#
# Set ENABLE_INFRA_SYNC to "true" in repo variables to activate this job.
sync-infra:
name: Sync vastlint-infra
needs: [publish-npm, release]
runs-on: ubuntu-latest
if: vars.ENABLE_INFRA_SYNC == 'true'
steps:
- name: Checkout vastlint-infra via deploy key
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: aleksUIX/vastlint-infra
ssh-key: ${{ secrets.VASTLINT_INFRA_DEPLOY_KEY }}
path: vastlint-infra
- name: Bump vastlint npm dependency to release version
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
# Update both the workspace root and the web app package.json
for PKG in "package.json" "apps/vastlint-web/package.json"; do
FILE="vastlint-infra/$PKG"
if [[ -f "$FILE" ]]; then
# Use node to update the version to avoid sed quoting issues
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('$FILE', 'utf8'));
const updated = { ...pkg };
for (const section of ['dependencies', 'devDependencies']) {
if (updated[section]?.vastlint !== undefined) {
updated[section].vastlint = '$VERSION';
}
}
fs.writeFileSync('$FILE', JSON.stringify(updated, null, 2) + '\n');
"
echo "Bumped vastlint in $PKG to ${VERSION}"
fi
done
- name: Import bot GPG key
run: |
echo "${{ secrets.BOT_GPG_KEY }}" | gpg --batch --import
echo "AEA67F4C8B714AC26E14ACDB67DF23E270B033DB:6:" | gpg --import-ownertrust
- name: Commit and push
run: |
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
cd vastlint-infra
git config user.name "vastlint release bot"
git config user.email "bot@vastlint.org"
git config user.signingkey "67DF23E270B033DB"
git config commit.gpgsign true
git add package.json apps/vastlint-web/package.json
git diff --cached --quiet && echo "nothing to commit" && exit 0
git commit -S -m "chore: bump vastlint npm dependency to ${TAG}"
git push origin main
# ── Sync homebrew-tap ───────────────────────────────────────────────────────
# After a successful CLI release, update the Homebrew formula in
# aleksUIX/homebrew-tap so the versioned URLs and all four platform SHA-256
# values always match the tarballs produced by this workflow run.
#
# Prerequisites (one-time setup):
# 1. Generate an SSH deploy key for homebrew-tap:
# ssh-keygen -t ed25519 -f ~/VASTLINT_HOMEBREW_TAP_DEPLOY_KEY -C "vastlint release bot (homebrew)" -N ""
# 2. Add ~/VASTLINT_HOMEBREW_TAP_DEPLOY_KEY.pub as a deploy key in
# aleksUIX/homebrew-tap (Settings → Deploy keys) with write access.
# 3. Add ~/VASTLINT_HOMEBREW_TAP_DEPLOY_KEY (private key) as secret
# VASTLINT_HOMEBREW_TAP_DEPLOY_KEY in this repo
# (Settings → Secrets → Actions).
#
# Set ENABLE_HOMEBREW_TAP_SYNC to "true" in repo variables to activate this
# job.
sync-homebrew-tap:
name: Sync homebrew-tap
needs: [build, release]
runs-on: ubuntu-latest
if: vars.ENABLE_HOMEBREW_TAP_SYNC == 'true'
steps:
- name: Download CLI release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: cli-artifacts
pattern: vastlint-*.tar.gz
merge-multiple: true
- name: Checkout homebrew-tap via deploy key
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: aleksUIX/homebrew-tap
ssh-key: ${{ secrets.VASTLINT_HOMEBREW_TAP_DEPLOY_KEY }}
path: homebrew-tap
- name: Update vastlint formula version and checksums
env:
TAG: ${{ github.ref_name }}
FORMULA: homebrew-tap/Formula/vastlint.rb
run: |
VERSION="${TAG#v}"
declare -A ARCHIVES=(
[SHA_MACOS_ARM64]=vastlint-macos-aarch64.tar.gz
[SHA_MACOS_X86_64]=vastlint-macos-x86_64.tar.gz
[SHA_LINUX_ARM64]=vastlint-linux-aarch64.tar.gz
[SHA_LINUX_X86_64]=vastlint-linux-x86_64.tar.gz
)
for KEY in "${!ARCHIVES[@]}"; do
FILE="cli-artifacts/${ARCHIVES[$KEY]}"
if [[ ! -f "$FILE" ]]; then
echo "Missing expected CLI archive: $FILE"
exit 1
fi
VALUE=$(sha256sum "$FILE" | awk '{print $1}')
export "$KEY=$VALUE"
echo "${ARCHIVES[$KEY]} => $VALUE"
done
export VERSION
ruby <<'RUBY'
formula_path = ENV.fetch('FORMULA')
version = ENV.fetch('VERSION')
replacements = {
'vastlint-macos-aarch64.tar.gz' => ENV.fetch('SHA_MACOS_ARM64'),
'vastlint-macos-x86_64.tar.gz' => ENV.fetch('SHA_MACOS_X86_64'),
'vastlint-linux-aarch64.tar.gz' => ENV.fetch('SHA_LINUX_ARM64'),
'vastlint-linux-x86_64.tar.gz' => ENV.fetch('SHA_LINUX_X86_64'),
}
content = File.read(formula_path)
content = content.sub(/version\s+"[^"]+"/, "version \"#{version}\"")
replacements.each do |archive, checksum|
pattern = %r{(url "https://github\.com/aleksUIX/vastlint/releases/download/)v[^/]+(/#{Regexp.escape(archive)}"\n\s+sha256 ")([0-9a-f]{64})(")}
updated = content.sub(pattern) do
"#{$1}v#{version}#{$2}#{checksum}#{$4}"
end
if updated == content
raise "failed to update #{archive} in #{formula_path}"
end
content = updated
end
File.write(formula_path, content)
RUBY
- name: Validate formula syntax
run: ruby -c homebrew-tap/Formula/vastlint.rb
- name: Import bot GPG key
run: |
echo "${{ secrets.BOT_GPG_KEY }}" | gpg --batch --import
echo "AEA67F4C8B714AC26E14ACDB67DF23E270B033DB:6:" | gpg --import-ownertrust
- name: Commit and push
run: |
TAG="${{ github.ref_name }}"
cd homebrew-tap
git config user.name "vastlint release bot"
git config user.email "bot@vastlint.org"
git config user.signingkey "67DF23E270B033DB"
git config commit.gpgsign true
git add Formula/vastlint.rb
git diff --cached --quiet && echo "nothing to commit" && exit 0
git commit -S -m "chore: update vastlint formula to ${TAG}"
git push origin main