diff --git a/.github/workflows/compat-date-check.yml b/.github/workflows/compat-date-check.yml new file mode 100644 index 00000000000..b4a82fd17b5 --- /dev/null +++ b/.github/workflows/compat-date-check.yml @@ -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. diff --git a/.github/workflows/compat-date-daily-check.yml b/.github/workflows/compat-date-daily-check.yml new file mode 100644 index 00000000000..ec5013f424a --- /dev/null +++ b/.github/workflows/compat-date-daily-check.yml @@ -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." diff --git a/src/workerd/tools/BUILD.bazel b/src/workerd/tools/BUILD.bazel index 960cccca274..9bd8efaa545 100644 --- a/src/workerd/tools/BUILD.bazel +++ b/src/workerd/tools/BUILD.bazel @@ -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 @@ -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", + ], +) diff --git a/src/workerd/tools/compare-compat-dates.py b/src/workerd/tools/compare-compat-dates.py new file mode 100755 index 00000000000..b9ef4e6f1cc --- /dev/null +++ b/src/workerd/tools/compare-compat-dates.py @@ -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()) diff --git a/src/workerd/tools/compatibility-date-dump-test.c++ b/src/workerd/tools/compatibility-date-dump-test.c++ new file mode 100644 index 00000000000..86495de2bc8 --- /dev/null +++ b/src/workerd/tools/compatibility-date-dump-test.c++ @@ -0,0 +1,233 @@ +// 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 + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace workerd { +namespace tools { +namespace { + +struct FlagEntry { + kj::StringPtr field; + kj::StringPtr enableFlag; + kj::Maybe disableFlag; + kj::Maybe date; + kj::StringPtr dateSource; +}; + +FlagInfoList::Reader buildFlagDump(capnp::MallocMessageBuilder& message) { + kj::Vector entries; + auto schema = capnp::Schema::from(); + + for (auto field: schema.getFields()) { + kj::StringPtr fieldName = field.getProto().getName(); + kj::Maybe enableFlag; + kj::Maybe disableFlag; + kj::Maybe date; + kj::StringPtr dateSource = ""; + + for (auto annotation: field.getProto().getAnnotations()) { + if (annotation.getId() == COMPAT_ENABLE_FLAG_ANNOTATION_ID) { + enableFlag = annotation.getValue().getText(); + } else if (annotation.getId() == COMPAT_DISABLE_FLAG_ANNOTATION_ID) { + disableFlag = annotation.getValue().getText(); + } else if (annotation.getId() == COMPAT_ENABLE_DATE_ANNOTATION_ID) { + date = annotation.getValue().getText(); + dateSource = "compatEnableDate"; + } else if (annotation.getId() == IMPLIED_BY_AFTER_DATE_ANNOTATION_ID) { + auto value = annotation.getValue(); + auto s = value.getStruct().getAs(); + date = s.getDate(); + dateSource = "impliedByAfterDate"; + } + } + + KJ_IF_SOME(flag, enableFlag) { + entries.add(FlagEntry{ + .field = fieldName, + .enableFlag = flag, + .disableFlag = disableFlag, + .date = date, + .dateSource = dateSource, + }); + } + } + + auto root = message.initRoot(); + auto list = root.initFlags(entries.size()); + + for (size_t i = 0; i < entries.size(); ++i) { + auto entry = entries[i]; + auto item = list[i]; + + item.setField(entry.field); + item.setEnableFlag(entry.enableFlag); + + KJ_IF_SOME(dflag, entry.disableFlag) { + item.setDisableFlag(dflag); + } else { + item.setDisableFlag(""); + } + + KJ_IF_SOME(d, entry.date) { + item.setDate(d); + item.setDateSource(entry.dateSource); + } else { + item.setDate(""); + item.setDateSource(""); + } + } + + return root.asReader(); +} + +KJ_TEST("known flags exist with correct dates") { + capnp::MallocMessageBuilder message; + auto dump = buildFlagDump(message); + auto flags = dump.getFlags(); + + bool foundFormData = false; + bool foundFetchRefuses = false; + bool foundStreamsConstructors = false; + + for (auto info: flags) { + if (info.getEnableFlag() == "formdata_parser_supports_files") { + foundFormData = true; + KJ_EXPECT(info.getDate() == "2021-11-03", info.getDate()); + KJ_EXPECT(info.getDateSource() == "compatEnableDate"); + } + + if (info.getEnableFlag() == "fetch_refuses_unknown_protocols") { + foundFetchRefuses = true; + KJ_EXPECT(info.getDate() == "2021-11-10", info.getDate()); + KJ_EXPECT(info.getDateSource() == "compatEnableDate"); + } + + if (info.getEnableFlag() == "streams_enable_constructors") { + foundStreamsConstructors = true; + KJ_EXPECT(info.getDate() == "2022-11-30", info.getDate()); + KJ_EXPECT(info.getDateSource() == "compatEnableDate"); + } + } + + KJ_EXPECT(foundFormData, "formdata_parser_supports_files flag not found"); + KJ_EXPECT(foundFetchRefuses, "fetch_refuses_unknown_protocols flag not found"); + KJ_EXPECT(foundStreamsConstructors, "streams_enable_constructors flag not found"); +} + +KJ_TEST("all flags have required fields") { + capnp::MallocMessageBuilder message; + auto dump = buildFlagDump(message); + auto flags = dump.getFlags(); + + KJ_EXPECT(flags.size() > 0, "Should have at least one flag"); + + for (auto info: flags) { + KJ_EXPECT(info.getField().size() > 0, "Field name should not be empty"); + KJ_EXPECT(info.getEnableFlag().size() > 0, "Enable flag should not be empty"); + } +} + +KJ_TEST("date format validation") { + capnp::MallocMessageBuilder message; + auto dump = buildFlagDump(message); + auto flags = dump.getFlags(); + + for (auto info: flags) { + auto date = info.getDate(); + if (date.size() == 0) { + continue; + } + + KJ_EXPECT(date.size() == 10, "Date should be 10 characters: ", date); + KJ_EXPECT(date[4] == '-', "Date should have dash at position 4: ", date); + KJ_EXPECT(date[7] == '-', "Date should have dash at position 7: ", date); + + for (int i = 0; i < 4; i++) { + KJ_EXPECT(date[i] >= '0' && date[i] <= '9', "Invalid year digit in: ", date); + } + + KJ_EXPECT(date[5] >= '0' && date[5] <= '1', "Invalid month in: ", date); + KJ_EXPECT(date[6] >= '0' && date[6] <= '9', "Invalid month in: ", date); + KJ_EXPECT(date[8] >= '0' && date[8] <= '3', "Invalid day in: ", date); + KJ_EXPECT(date[9] >= '0' && date[9] <= '9', "Invalid day in: ", date); + } +} + +KJ_TEST("date source consistency") { + capnp::MallocMessageBuilder message; + auto dump = buildFlagDump(message); + auto flags = dump.getFlags(); + + for (auto info: flags) { + auto date = info.getDate(); + auto source = info.getDateSource(); + + if (date.size() > 0) { + KJ_EXPECT(source.size() > 0, + "Date source should be set when date is present for field: ", info.getField()); + KJ_EXPECT(source == "compatEnableDate" || source == "impliedByAfterDate", + "Date source should be 'compatEnableDate' or 'impliedByAfterDate', got: ", source); + } else { + KJ_EXPECT(source.size() == 0, + "Date source should be empty when date is not present for field: ", info.getField()); + } + } +} + +KJ_TEST("no duplicate enable flags") { + capnp::MallocMessageBuilder message; + auto dump = buildFlagDump(message); + auto flags = dump.getFlags(); + + kj::HashSet seenFlags; + for (auto info: flags) { + auto flag = info.getEnableFlag(); + KJ_EXPECT(!seenFlags.contains(flag), "Duplicate enable flag found: ", flag); + seenFlags.insert(flag); + } +} + +KJ_TEST("impliedByAfterDate flags exist") { + capnp::MallocMessageBuilder message; + auto dump = buildFlagDump(message); + auto flags = dump.getFlags(); + + bool foundImpliedBy = false; + for (auto info: flags) { + if (info.getDateSource() == "impliedByAfterDate") { + foundImpliedBy = true; + break; + } + } + + KJ_EXPECT(foundImpliedBy, "Should have at least one flag with impliedByAfterDate"); +} + +KJ_TEST("json codec encodes and decodes dump") { + capnp::MallocMessageBuilder message; + auto dump = buildFlagDump(message); + + capnp::JsonCodec json; + auto encoded = json.encode(dump); + + capnp::MallocMessageBuilder decodedMessage; + auto decodedBuilder = decodedMessage.initRoot(); + json.decode(encoded.asArray(), decodedBuilder); + auto decoded = decodedBuilder.asReader(); + + KJ_EXPECT(decoded.getFlags().size() == dump.getFlags().size()); +} + +} // namespace +} // namespace tools +} // namespace workerd diff --git a/src/workerd/tools/compatibility-date-dump.c++ b/src/workerd/tools/compatibility-date-dump.c++ new file mode 100644 index 00000000000..2a74de12daf --- /dev/null +++ b/src/workerd/tools/compatibility-date-dump.c++ @@ -0,0 +1,126 @@ +// 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 + +// Tool to dump all compatibility flags with their dates as JSON. +// Used by CI to validate that new flags have dates sufficiently far in the future. + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace workerd { +namespace tools { +namespace { + +struct FlagEntry { + kj::StringPtr field; + kj::StringPtr enableFlag; + kj::Maybe disableFlag; + kj::Maybe date; + kj::StringPtr dateSource; +}; + +class CompatibilityDateDump { + public: + explicit CompatibilityDateDump(kj::ProcessContext& context): context(context) {} + + kj::MainFunc getMain() { + return kj::MainBuilder(context, "compatibility-date-dump", + "Dumps all compatibility flags with their dates as JSON.\n" + "Output format: {\"flags\": [{\"field\": \"name\", " + "\"enableFlag\": \"flag\", \"date\": \"YYYY-MM-DD\", " + "\"dateSource\": \"source\"}, ...]}") + .callAfterParsing(KJ_BIND_METHOD(*this, run)) + .build(); + } + + kj::MainBuilder::Validity run() { + kj::FdOutputStream out(STDOUT_FILENO); + kj::Vector entries; + + auto schema = capnp::Schema::from(); + + for (auto field: schema.getFields()) { + kj::StringPtr fieldName = field.getProto().getName(); + kj::Maybe enableFlag; + kj::Maybe disableFlag; + kj::Maybe date; + kj::StringPtr dateSource = ""; + + for (auto annotation: field.getProto().getAnnotations()) { + if (annotation.getId() == COMPAT_ENABLE_FLAG_ANNOTATION_ID) { + enableFlag = annotation.getValue().getText(); + } else if (annotation.getId() == COMPAT_DISABLE_FLAG_ANNOTATION_ID) { + disableFlag = annotation.getValue().getText(); + } else if (annotation.getId() == COMPAT_ENABLE_DATE_ANNOTATION_ID) { + date = annotation.getValue().getText(); + dateSource = "compatEnableDate"; + } else if (annotation.getId() == IMPLIED_BY_AFTER_DATE_ANNOTATION_ID) { + auto value = annotation.getValue(); + auto s = value.getStruct().getAs(); + date = s.getDate(); + dateSource = "impliedByAfterDate"; + } + } + + KJ_IF_SOME(flag, enableFlag) { + entries.add(FlagEntry{ + .field = fieldName, + .enableFlag = flag, + .disableFlag = disableFlag, + .date = date, + .dateSource = dateSource, + }); + } + } + + capnp::MallocMessageBuilder message; + auto root = message.initRoot(); + auto list = root.initFlags(entries.size()); + + for (size_t i = 0; i < entries.size(); ++i) { + auto entry = entries[i]; + auto item = list[i]; + + item.setField(entry.field); + item.setEnableFlag(entry.enableFlag); + + KJ_IF_SOME(dflag, entry.disableFlag) { + item.setDisableFlag(dflag); + } else { + item.setDisableFlag(""); + } + + KJ_IF_SOME(d, entry.date) { + item.setDate(d); + item.setDateSource(entry.dateSource); + } else { + item.setDate(""); + item.setDateSource(""); + } + } + + capnp::JsonCodec json; + auto encoded = json.encode(root); + out.write({encoded.asBytes(), "\n"_kj.asBytes()}); + return true; + } + + private: + kj::ProcessContext& context; +}; + +} // namespace +} // namespace tools +} // namespace workerd + +KJ_MAIN(workerd::tools::CompatibilityDateDump) diff --git a/src/workerd/tools/compatibility-date-dump.schema.capnp b/src/workerd/tools/compatibility-date-dump.schema.capnp new file mode 100644 index 00000000000..53af9819867 --- /dev/null +++ b/src/workerd/tools/compatibility-date-dump.schema.capnp @@ -0,0 +1,20 @@ +# 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 + +@0xb4c39b5e1a2369f2; + +using Cxx = import "/capnp/c++.capnp"; +$Cxx.namespace("workerd::tools"); + +struct FlagInfo @0x9c1863b221b3e2aa { + field @0 :Text; + enableFlag @1 :Text; + disableFlag @2 :Text; + date @3 :Text; + dateSource @4 :Text; +} + +struct FlagInfoList @0xd2088d55b2e71c1f { + flags @0 :List(FlagInfo); +}