Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/compat-date-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Compatibility Date Check

on:
pull_request:
paths:
- 'src/workerd/io/compatibility-date.capnp'
types: [opened, synchronize, labeled, unlabeled]
workflow_dispatch:
inputs:
min_days:
description: 'Minimum days in the future for new flag dates'
required: false
default: 7
type: number

concurrency:
group: compat-date-check-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: write

jobs:
check-compat-dates:
runs-on: ubuntu-latest
# Skip if PR has the bypass label
if: ${{ !contains(github.event.pull_request.labels.*.name, 'urgent-compat-flag') }}

steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
show-progress: false

- name: Setup Runner
uses: ./.github/actions/setup-runner

- name: Build flag dumper (PR branch)
run: |
bazel build //src/workerd/tools:compatibility-date-dump

- name: Get PR flags
run: |
bazel-bin/src/workerd/tools/compatibility-date-dump > pr-flags.json

- name: Checkout base branch
run: |
git checkout ${{ github.event.pull_request.base.sha }}

- name: Build flag dumper (base branch)
run: |
bazel build //src/workerd/tools:compatibility-date-dump

- name: Get baseline flags
run: |
bazel-bin/src/workerd/tools/compatibility-date-dump > baseline-flags.json

- name: Validate new flag dates
id: validate
run: |
python3 src/workerd/tools/compare-compat-dates.py \
pr-flags.json \
baseline-flags.json \
--min-days ${{ inputs.min_days || '7' }}

- name: Post comment on failure
if: failure() && steps.validate.outputs.has_violations == 'true'
uses: marocchino/sticky-pull-request-comment@v2
with:
path: violation-report.md

- name: Remove comment on success
if: success()
uses: marocchino/sticky-pull-request-comment@v2
with:
only_update: true
message: |
✅ Compatibility date validation passed. All new flags have dates sufficiently far in the future.
69 changes: 69 additions & 0 deletions .github/workflows/compat-date-daily-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Daily Compatibility Date Check

on:
schedule:
# Run at 8 AM UTC daily
- cron: '0 8 * * *'
workflow_dispatch:
inputs:
min_days:
description: 'Minimum days in the future for new flag dates'
required: false
default: 7
type: number

permissions:
contents: read
pull-requests: write
actions: write

jobs:
check-open-prs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
show-progress: false

- name: Find and check open PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MIN_DAYS: ${{ inputs.min_days || '7' }}
run: |
echo "Checking open PRs that modify compatibility-date.capnp..."

# Get all open PRs
PRS=$(gh pr list --state open --json number,headRefName,labels --jq '.')

echo "Found $(echo "$PRS" | jq 'length') open PRs"

# Track PRs to check
PRS_TO_CHECK=""

echo "$PRS" | jq -c '.[]' | while read -r pr; do
PR_NUM=$(echo "$pr" | jq -r '.number')
LABELS=$(echo "$pr" | jq -r '[.labels[].name] | join(",")')

# Skip if PR has bypass label
if echo "$LABELS" | grep -q "urgent-compat-flag"; then
echo "PR #$PR_NUM: Skipping (has urgent-compat-flag label)"
continue
fi

# Check if PR modifies the capnp file
FILES_CHANGED=$(gh pr diff "$PR_NUM" --name-only 2>/dev/null || echo "")

if echo "$FILES_CHANGED" | grep -q "src/workerd/io/compatibility-date.capnp"; then
echo "PR #$PR_NUM: Modifies compatibility-date.capnp - triggering check"

# Trigger the compat-date-check workflow for this PR
# Note: This will re-run the check with current date
gh workflow run compat-date-check.yml \
--ref "refs/pull/$PR_NUM/merge" \
-f min_days="$MIN_DAYS" || echo "Failed to trigger workflow for PR #$PR_NUM"
else
echo "PR #$PR_NUM: Does not modify compatibility-date.capnp - skipping"
fi
done

echo "Daily check completed."
40 changes: 40 additions & 0 deletions src/workerd/tools/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
load("@bazel_skylib//rules:run_binary.bzl", "run_binary")
load("@rules_rust//rust:defs.bzl", "rust_binary")
load("//:build/cc_ast_dump.bzl", "cc_ast_dump")
load("//:build/kj_test.bzl", "kj_test")
load("//:build/wd_capnp_library.bzl", "wd_capnp_library")
load("//:build/wd_cc_binary.bzl", "wd_cc_binary")

# ========================================================================================
# Parameter Name Extractor
Expand Down Expand Up @@ -82,3 +85,40 @@ run_binary(
tool = "param_extractor_bin",
visibility = ["//visibility:public"],
)

# ========================================================================================
# Compatibility Date Dump
#
# Dumps all compatibility flags with their dates as JSON for CI validation.
# Used to ensure new flags have dates sufficiently far in the future.

wd_capnp_library(src = "compatibility-date-dump.schema.capnp")

wd_cc_binary(
name = "compatibility-date-dump",
srcs = ["compatibility-date-dump.c++"],
target_compatible_with = select({
"@platforms//os:windows": ["@platforms//:incompatible"],
"//conditions:default": [],
}),
visibility = ["//visibility:public"],
deps = [
":compatibility-date-dump.schema_capnp",
"//src/workerd/io",
"//src/workerd/io:compatibility-date_capnp",
"@capnp-cpp//src/capnp",
"@capnp-cpp//src/capnp/compat:json",
"@capnp-cpp//src/kj",
],
)

kj_test(
src = "compatibility-date-dump-test.c++",
deps = [
":compatibility-date-dump.schema_capnp",
"//src/workerd/io",
"//src/workerd/io:compatibility-date_capnp",
"@capnp-cpp//src/capnp",
"@capnp-cpp//src/capnp/compat:json",
],
)
165 changes: 165 additions & 0 deletions src/workerd/tools/compare-compat-dates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
# Copyright (c) 2017-2022 Cloudflare, Inc.
# Licensed under the Apache 2.0 license found in the LICENSE file or at:
# https://opensource.org/licenses/Apache-2.0

"""
Compares compatibility flags between PR and baseline to validate date requirements.
Used by CI to ensure new flags have dates sufficiently far in the future.
"""

import argparse
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path


def load_flags(filepath):
"""Load and parse JSON flags file."""
try:
with Path(filepath).open() as f:
data = json.load(f)
return data.get("flags", [])
except FileNotFoundError:
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(2)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {filepath}: {e}", file=sys.stderr)
sys.exit(2)


def find_new_flags(pr_flags, baseline_flags):
"""Find flags that exist in PR but not in baseline."""
baseline_names = {flag["enableFlag"] for flag in baseline_flags}
return [flag for flag in pr_flags if flag["enableFlag"] not in baseline_names]


def calculate_min_date(min_days):
"""Calculate minimum allowed date (today + min_days)."""
return (datetime.now() + timedelta(days=min_days)).strftime("%Y-%m-%d")


def find_violations(new_flags, min_date):
"""Find new flags with dates earlier than min_date."""
violations = []
for flag in new_flags:
date = flag.get("date", "")
if date and date < min_date:
violations.append(
{
"field": flag["field"],
"enableFlag": flag["enableFlag"],
"date": date,
"minDate": min_date,
}
)
return violations


def generate_violation_report(violations, min_days):
"""Generate markdown report for violations."""
report_lines = [
"## ⚠️ Compatibility Date Validation Failed",
"",
f"New compatibility flags must have dates at least **{min_days} days** in the future.",
"",
"| Field | Flag Name | Current Date | Minimum Required |",
"|-------|-----------|--------------|------------------|",
]

report_lines.extend(
[
f"| `{v['field']}` | `{v['enableFlag']}` | {v['date']} | {v['minDate']} |"
for v in violations
]
)

report_lines.extend(
[
"",
"### How to fix",
f"Update the `$compatEnableDate` in `compatibility-date.capnp` to a date >= **{violations[0]['minDate']}**.",
"",
"### Bypass",
"If this is urgent, add the `urgent-compat-flag` label to bypass this check.",
]
)

return "\n".join(report_lines)


def set_github_output(key, value):
"""Set GitHub Actions output variable."""
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with Path(github_output).open("a") as f:
f.write(f"{key}={value}\n")


def main():
parser = argparse.ArgumentParser(
description="Compare compatibility flags between PR and baseline"
)
parser.add_argument("pr_flags", help="Path to PR flags JSON file")
parser.add_argument("baseline_flags", help="Path to baseline flags JSON file")
parser.add_argument(
"--min-days",
type=int,
default=7,
help="Minimum days in the future for new flag dates (default: 7)",
)

args = parser.parse_args()

# Load flags from both files
pr_flags = load_flags(args.pr_flags)
baseline_flags = load_flags(args.baseline_flags)

# Find new flags
new_flags = find_new_flags(pr_flags, baseline_flags)

if not new_flags:
print("No new compatibility flags found.")
set_github_output("has_violations", "false")
return 0

# Calculate minimum date
min_date = calculate_min_date(args.min_days)
print(f"Minimum allowed date: {min_date} (today + {args.min_days} days)")

# Print new flags
print("\nNew flags found:")
for flag in new_flags:
print(f" - {flag['enableFlag']}")

# Check for violations
violations = find_violations(new_flags, min_date)

if not violations:
print(f"\n✓ All new compatibility flag dates are valid (>= {min_date})")
set_github_output("has_violations", "false")
return 0

# Report violations
set_github_output("has_violations", "true")

# Generate and write markdown report
report = generate_violation_report(violations, args.min_days)
with Path("violation-report.md").open("w") as f:
f.write(report)

# Print report to stdout
print(f"\n{report}")

# Print GitHub error
print(
f"\n::error::New compatibility flags must have dates at least {args.min_days} days in the future"
)

return 1


if __name__ == "__main__":
sys.exit(main())
Loading
Loading