From 0e9ae76ea2a3d022b96ab7a464a1c437841dae99 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:10:59 +0000 Subject: [PATCH 1/3] ci(lock): lock taiki-e/install-action@v2.87.26 used by mvp-smoke and s4-loop PR #412 (dependabot, actions group) bumped the workflow refs from v2.87.22 to v2.87.26 but did not update actions.lock. The lock check ('Verify immutable action lockfile') then fails on main, which is the Workflow Security Linter red since #412. The entry is the v2.87.26 tag commit (f7e5d7c), with the same owner_id and repo_id as before. No workflow references v2.87.22 any more, so that entry is replaced rather than kept. Verified: no workflow file references the unlocked ref; the change is one entry replaced in the lock. The authoritative gh-actions-lock check could not be run from the agent sandbox (its release download is outside the allow-list), so CI is the verifier. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/actions.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 091a936a..bad3397a 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -59,7 +59,7 @@ workflows: - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.2' - - 'taiki-e/install-action@v2.87.22' + - 'taiki-e/install-action@v2.87.26' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' @@ -75,7 +75,7 @@ workflows: - 'actions/checkout@v7.0.1' - 'dtolnay/rust-toolchain@v1' - 'swatinem/rust-cache@v2.9.2' - - 'taiki-e/install-action@v2.87.22' + - 'taiki-e/install-action@v2.87.26' '.github/workflows/scorecard.yml': [] '.github/workflows/secret-scanner.yml': [] '.github/workflows/security-scan.yml': [] @@ -192,8 +192,8 @@ dependencies: commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' owner_id: 580492 repo_id: 298565987 - 'taiki-e/install-action@v2.87.22': - ref: 'v2.87.22' - commit: 'sha1-83ac0ad63c0167e6f06796fab0fce28db1bf3db0' + 'taiki-e/install-action@v2.87.26': + ref: 'v2.87.26' + commit: 'sha1-f7e5d7c961414b23f5b25b2da9294395d08513ad' owner_id: 43724913 repo_id: 442947557 From 329b6830c74365c2518395d80ac58a67a33afe75 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:20:46 +0000 Subject: [PATCH 2/3] ci: rewrite CI checkers in bash+jq; estate policy bans Python #417 added scripts/ci/check-allow-fail-expiry.py and check-ruleset-drift.py. The estate language policy bans Python, and `main` had no other .py file, so the governance "banned-language files" check fails on these two. The same merge turned the Static Analysis Gate red on `main` (it passed at 645d7a2), so the Python files are also the first suspect for that regression. The Hypatia job log is not readable from the sandbox, so this is not confirmed. Both checkers are ported to bash with jq (already used by scripts/ci/triage-hypatia.jq). Behaviour, messages and exit codes are kept: - check-allow-fail-expiry.sh: identical output to the Python version at 2026-10-09 (14 units, exit 0) and 2026-12-01 (exit 1), and identical on missing-marker, invalid-date and warning-window fixtures. - check-ruleset-drift.sh: zero drift against live GitHub (5/5), matches the Python version on a tampered snapshot, and --write reproduces the committed snapshots byte-for-byte after canonicalisation. References updated: workflow-linter step (run via bash), rulesets README, and the ultraplan. The ultraplan's "Python link checker" row now says it was a scratch tool that is not committed. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/rulesets/README.adoc | 2 +- .github/workflows/workflow-linter.yml | 2 +- docs/planning/ULTRAPLAN-2026-10-09.adoc | 4 +- scripts/ci/check-allow-fail-expiry.py | 73 ------------ scripts/ci/check-allow-fail-expiry.sh | 107 +++++++++++++++++ scripts/ci/check-ruleset-drift.py | 88 -------------- scripts/ci/check-ruleset-drift.sh | 126 ++++++++++++++++++++ scripts/ci/check-workflow-duplicate-keys.sh | 0 scripts/ci/test-issue-regressions.sh | 0 9 files changed, 237 insertions(+), 165 deletions(-) delete mode 100755 scripts/ci/check-allow-fail-expiry.py create mode 100755 scripts/ci/check-allow-fail-expiry.sh delete mode 100755 scripts/ci/check-ruleset-drift.py create mode 100755 scripts/ci/check-ruleset-drift.sh mode change 100644 => 100755 scripts/ci/check-workflow-duplicate-keys.sh mode change 100644 => 100755 scripts/ci/test-issue-regressions.sh diff --git a/.github/rulesets/README.adoc b/.github/rulesets/README.adoc index e3ee2b90..5b735ca4 100644 --- a/.github/rulesets/README.adoc +++ b/.github/rulesets/README.adoc @@ -158,7 +158,7 @@ Check for drift against live state (needs repository administration read): [source,bash] ---- -GH_TOKEN= python3 scripts/ci/check-ruleset-drift.py +GH_TOKEN= scripts/ci/check-ruleset-drift.sh ---- Exit 0 means no drift, 1 means drift, 2 means the rulesets could not be read. diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 9b8fdae1..ce46a7b3 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -34,7 +34,7 @@ jobs: # Ultraplan Phase 2.3 (tracking issue #416): every continue-on-error unit # needs an owner issue and an unexpired date on the same line. - name: Allow-fail units have an owner and an unexpired date - run: python3 scripts/ci/check-allow-fail-expiry.py + run: bash scripts/ci/check-allow-fail-expiry.sh - name: Verify immutable action lockfile with the authoritative tool env: diff --git a/docs/planning/ULTRAPLAN-2026-10-09.adoc b/docs/planning/ULTRAPLAN-2026-10-09.adoc index 81f8031e..7c5fc444 100644 --- a/docs/planning/ULTRAPLAN-2026-10-09.adoc +++ b/docs/planning/ULTRAPLAN-2026-10-09.adoc @@ -151,7 +151,7 @@ Status as of 2026-10-09, after the owner's first response. Phase 1 verdict) follows. * *Done in the CI hygiene PR:* the nine dead playground workflows moved to `echidna-playground/ci-templates/` (P0.6). All 14 allow-fail units carry an - owner issue and an expiry, enforced by `scripts/ci/check-allow-fail-expiry.py` + owner issue and an expiry, enforced by `scripts/ci/check-allow-fail-expiry.sh` in the workflow linter (Phase 2.3, tracking issue #416). A live ruleset snapshot and a drift check were added (P0.5, partial). * *Owner-held and not yet done:* rotate `VERISIMDB_PAT` (#310); add a @@ -373,7 +373,7 @@ reflects consequence if an outside user relies on the claim. is dated 2026-03-24. `docs/tech-debt-2026-05-26.adoc` says it is superseded and points to a root `DEBT.md` that is not present. The ROADMAP was last revised 2026-06-01. -| Python link checker; file headers +| Scratch link checker (not committed); file headers | *Medium* | L14 diff --git a/scripts/ci/check-allow-fail-expiry.py b/scripts/ci/check-allow-fail-expiry.py deleted file mode 100755 index 80ac7b18..00000000 --- a/scripts/ci/check-allow-fail-expiry.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MPL-2.0 -"""Fail CI when an allow-fail unit lacks an owner or an expiry, or has expired. - -Every workflow line that sets `continue-on-error` to anything other than the -literal `false` must carry, on the same line: - - # allow-fail: issue=# expires=YYYY-MM-DD - -The checker is deliberately text-based: it needs no YAML library, and it sees -the same literal the runner sees. It does not decide what happens at expiry. -A person does (ultraplan Phase 2.3, tracking issue #416). - -Usage: check-allow-fail-expiry.py [--today YYYY-MM-DD] [--warn-days N] [DIR ...] -""" -import argparse -import datetime as dt -import glob -import os -import re -import sys - -KEY = re.compile(r"^\s*continue-on-error:\s*(?P[^#]*?)\s*(?:#.*)?$") -MARK = re.compile(r"#\s*allow-fail:\s*issue=#(?P\d+)\s+expires=(?P\d{4}-\d{2}-\d{2})\b") - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("dirs", nargs="*", default=[".github/workflows"]) - ap.add_argument("--today", default=None) - ap.add_argument("--warn-days", type=int, default=21) - args = ap.parse_args() - today = dt.date.fromisoformat(args.today) if args.today else dt.date.today() - - errors, warnings, units = [], [], 0 - files = [] - for d in args.dirs: - files += sorted(glob.glob(os.path.join(d, "*.yml")) + glob.glob(os.path.join(d, "*.yaml"))) - - for path in files: - with open(path, encoding="utf-8") as fh: - for lineno, line in enumerate(fh, 1): - m = KEY.match(line.rstrip("\n")) - if not m or m.group("value") == "false": - continue - units += 1 - where = f"{path}:{lineno}" - mk = MARK.search(line) - if not mk: - errors.append(f"{where}: continue-on-error without '# allow-fail: issue=#N expires=YYYY-MM-DD'") - continue - try: - expiry = dt.date.fromisoformat(mk.group("date")) - except ValueError: - errors.append(f"{where}: invalid expiry date {mk.group('date')}") - continue - if expiry < today: - errors.append(f"{where}: allow-fail expired on {expiry} (issue #{mk.group('issue')}); make it blocking, re-justify, or remove") - elif (expiry - today).days <= args.warn_days: - warnings.append(f"{where}: expires {expiry} (issue #{mk.group('issue')})") - - for w in warnings: - print(f"::warning::{w}") - print(f"allow-fail units checked: {units}") - if errors: - for e in errors: - print(f"::error::{e}") - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/check-allow-fail-expiry.sh b/scripts/ci/check-allow-fail-expiry.sh new file mode 100755 index 00000000..9af3bdf4 --- /dev/null +++ b/scripts/ci/check-allow-fail-expiry.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Fail CI when an allow-fail unit lacks an owner or an expiry, or has expired. +# +# Every workflow line that sets `continue-on-error` to anything other than the +# literal `false` must carry, on the same line: +# +# # allow-fail: issue=# expires=YYYY-MM-DD +# +# The checker is deliberately text-based: it needs no YAML library, and it sees +# the same literal the runner sees. It does not decide what happens at expiry. +# A person does (ultraplan Phase 2.3, tracking issue #416). +# +# Usage: check-allow-fail-expiry.sh [--today YYYY-MM-DD] [--warn-days N] [DIR ...] +# +# Exit codes: 0 all units owned and in date, 1 a unit is unowned, undated, +# or expired, 2 bad arguments. + +set -euo pipefail +export TZ=UTC + +today="" +warn_days=21 +dirs=() + +while [ $# -gt 0 ]; do + case "$1" in + --today) [ $# -ge 2 ] || { echo "--today needs a value" >&2; exit 2; }; today="$2"; shift 2 ;; + --warn-days) [ $# -ge 2 ] || { echo "--warn-days needs a value" >&2; exit 2; }; warn_days="$2"; shift 2 ;; + -h|--help) sed -n '3,20p' "$0"; exit 0 ;; + --) shift; dirs+=("$@"); break ;; + -*) echo "unknown option: $1" >&2; exit 2 ;; + *) dirs+=("$1"); shift ;; + esac +done + +if [ ${#dirs[@]} -eq 0 ]; then + dirs=(.github/workflows) +fi +if [ -z "$today" ]; then + today=$(date +%F) +fi +if ! today_s=$(date -d "$today" +%s 2>/dev/null); then + echo "invalid --today: $today" >&2 + exit 2 +fi +if ! [[ $warn_days =~ ^[0-9]+$ ]]; then + echo "--warn-days must be a non-negative integer" >&2 + exit 2 +fi + +shopt -s nullglob +files=() +for d in "${dirs[@]}"; do + for f in "$d"/*.yml "$d"/*.yaml; do + files+=("$f") + done +done + +errors=() +warnings=() +units=0 + +for file in "${files[@]}"; do + lineno=0 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + # Same shape as the old Python KEY pattern: the key must open the line. + [[ $line =~ ^[[:space:]]*continue-on-error:[[:space:]]*(.*)$ ]] || continue + value=$(printf '%s' "${BASH_REMATCH[1]}" | sed -E 's/[[:space:]]*(#.*)?$//') + [ "$value" = "false" ] && continue + + units=$((units + 1)) + where="$file:$lineno" + mark=$(printf '%s' "$line" \ + | grep -oE '#[[:space:]]*allow-fail:[[:space:]]*issue=#[0-9]+[[:space:]]+expires=[0-9]{4}-[0-9]{2}-[0-9]{2}([^[:alnum:]_]|$)' \ + | head -1 || true) + if [ -z "$mark" ]; then + errors+=("$where: continue-on-error without '# allow-fail: issue=#N expires=YYYY-MM-DD'") + continue + fi + issue=$(printf '%s' "$mark" | sed -E 's/.*issue=#([0-9]+).*/\1/') + expiry=$(printf '%s' "$mark" | sed -E 's/.*expires=([0-9]{4}-[0-9]{2}-[0-9]{2}).*/\1/') + if ! expiry_s=$(date -d "$expiry" +%s 2>/dev/null); then + errors+=("$where: invalid expiry date $expiry") + continue + fi + if [ "$expiry_s" -lt "$today_s" ]; then + errors+=("$where: allow-fail expired on $expiry (issue #$issue); make it blocking, re-justify, or remove") + elif [ $(( (expiry_s - today_s) / 86400 )) -le "$warn_days" ]; then + warnings+=("$where: expires $expiry (issue #$issue)") + fi + done < "$file" +done + +for w in ${warnings[@]+"${warnings[@]}"}; do + echo "::warning::$w" +done +echo "allow-fail units checked: $units" +if [ ${#errors[@]} -gt 0 ]; then + for e in "${errors[@]}"; do + echo "::error::$e" + done + exit 1 +fi +exit 0 diff --git a/scripts/ci/check-ruleset-drift.py b/scripts/ci/check-ruleset-drift.py deleted file mode 100755 index 44b1fda3..00000000 --- a/scripts/ci/check-ruleset-drift.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MPL-2.0 -"""Compare the committed ruleset snapshot with the rulesets GitHub enforces. - -Snapshot files live in .github/rulesets/live/.json, one per ruleset, -keyed by the ruleset `id`. The top-level .github/rulesets/*.json files are -historical payloads and are not compared. - -Reading rulesets needs repository administration read. Run this with a token -that has it (`GH_TOKEN=... python3 scripts/ci/check-ruleset-drift.py`). The -default GITHUB_TOKEN may not be enough in Actions; see the workflow note in -.github/rulesets/README.adoc. Exit codes: 0 no drift, 1 drift, 2 cannot read. - -Regenerate the snapshot after an intentional change, not to silence drift: - python3 scripts/ci/check-ruleset-drift.py --write -""" -import argparse -import glob -import json -import os -import subprocess -import sys - -VOLATILE = {"_links", "created_at", "updated_at", "node_id", "current_user_can_bypass", "source", "source_type"} - - -def normalise(obj): - if isinstance(obj, dict): - return {k: normalise(v) for k, v in obj.items() if k not in VOLATILE} - if isinstance(obj, list): - return [normalise(v) for v in obj] - return obj - - -def gh_json(args): - out = subprocess.run(["gh", "api", *args], check=True, capture_output=True, text=True) - return json.loads(out.stdout) - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--repo", default="hyperpolymath/echidna") - ap.add_argument("--dir", default=".github/rulesets/live") - ap.add_argument("--write", action="store_true") - args = ap.parse_args() - - try: - listing = gh_json([f"repos/{args.repo}/rulesets"]) - except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError) as exc: - print(f"cannot read rulesets for {args.repo}: {exc}", file=sys.stderr) - return 2 - - live = {} - for r in listing: - live[r["id"]] = normalise(gh_json([f"repos/{args.repo}/rulesets/{r['id']}"])) - - if args.write: - os.makedirs(args.dir, exist_ok=True) - for rid, body in live.items(): - name = body["name"].replace("/", "-") - with open(os.path.join(args.dir, f"{name}.json"), "w", encoding="utf-8") as fh: - json.dump(body, fh, indent=2, sort_keys=True) - fh.write("\n") - print(f"wrote {len(live)} ruleset snapshot(s) to {args.dir}") - return 0 - - drift, seen = [], set() - for path in sorted(glob.glob(os.path.join(args.dir, "*.json"))): - with open(path, encoding="utf-8") as fh: - snap = normalise(json.load(fh)) - rid = snap.get("id") - seen.add(rid) - if rid not in live: - drift.append(f"{path}: ruleset {rid} ({snap.get('name')}) is in the snapshot but not enforced") - elif live[rid] != snap: - drift.append(f"{path}: ruleset {rid} ({snap.get('name')}) differs from the enforced state") - for rid, body in live.items(): - if rid not in seen: - drift.append(f"ruleset {rid} ({body.get('name')}) is enforced but has no snapshot") - - for d in drift: - print(f"::error::{d}") - print(f"rulesets enforced: {len(live)}, snapshots: {len(seen)}, drift: {len(drift)}") - return 1 if drift else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/check-ruleset-drift.sh b/scripts/ci/check-ruleset-drift.sh new file mode 100755 index 00000000..34ed6e00 --- /dev/null +++ b/scripts/ci/check-ruleset-drift.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# Compare the committed ruleset snapshot with the rulesets GitHub enforces. +# +# Snapshot files live in .github/rulesets/live/.json, one per ruleset, +# keyed by the ruleset `id`. The top-level .github/rulesets/*.json files are +# historical payloads and are not compared. +# +# Reading rulesets needs repository administration read. Run this with a token +# that has it (`GH_TOKEN=... scripts/ci/check-ruleset-drift.sh`). The default +# GITHUB_TOKEN may not be enough in Actions; see the workflow note in +# .github/rulesets/README.adoc. Exit codes: 0 no drift, 1 drift, 2 cannot read. +# +# Regenerate the snapshot after an intentional change, not to silence drift: +# scripts/ci/check-ruleset-drift.sh --write +# +# Requires gh and jq. + +set -euo pipefail + +repo="hyperpolymath/echidna" +dir=".github/rulesets/live" +write=0 + +while [ $# -gt 0 ]; do + case "$1" in + --repo) [ $# -ge 2 ] || { echo "--repo needs a value" >&2; exit 2; }; repo="$2"; shift 2 ;; + --dir) [ $# -ge 2 ] || { echo "--dir needs a value" >&2; exit 2; }; dir="$2"; shift 2 ;; + --write) write=1; shift ;; + -h|--help) sed -n '3,19p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +for tool in gh jq; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "cannot read rulesets for $repo: $tool is not installed" >&2 + exit 2 + fi +done + +# Fields that change without a configuration change. Removed at every depth, +# then keys are sorted, so the comparison is on configuration only. +NORM=' + def norm: + if type == "object" then + with_entries(select(.key as $k | (["_links","created_at","updated_at","node_id","current_user_can_bypass","source","source_type"] | index($k)) == null)) + | map_values(norm) + elif type == "array" then map(norm) + else . end; + norm' + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# Read the enforced rulesets. Any failure here is "cannot read", exit 2. +if ! listing=$(gh api "repos/$repo/rulesets" 2>"$tmp/err"); then + echo "cannot read rulesets for $repo: $(cat "$tmp/err")" >&2 + exit 2 +fi +if ! jq -e 'type == "array"' <<<"$listing" >/dev/null 2>&1; then + echo "cannot read rulesets for $repo: listing is not a JSON array" >&2 + exit 2 +fi + +live_ids=() +while IFS= read -r rid; do + [ -n "$rid" ] || continue + live_ids+=("$rid") + if ! gh api "repos/$repo/rulesets/$rid" 2>"$tmp/err" \ + | jq -S "$NORM" > "$tmp/live-$rid.json" 2>>"$tmp/err"; then + echo "cannot read ruleset $rid for $repo: $(cat "$tmp/err")" >&2 + exit 2 + fi +done < <(jq -r '.[].id' <<<"$listing") + +if [ "$write" -eq 1 ]; then + mkdir -p "$dir" + for rid in ${live_ids[@]+"${live_ids[@]}"}; do + name=$(jq -r '.name' "$tmp/live-$rid.json" | tr '/' '-') + cp "$tmp/live-$rid.json" "$dir/$name.json" + done + echo "wrote ${#live_ids[@]} ruleset snapshot(s) to $dir" + exit 0 +fi + +drift=() +seen=() +shopt -s nullglob +for snap in "$dir"/*.json; do + rid=$(jq -r '.id // empty' "$snap") + name=$(jq -r '.name // ""' "$snap") + seen+=("$rid") + if [ -z "$rid" ] || [ ! -f "$tmp/live-$rid.json" ]; then + drift+=("$snap: ruleset $rid ($name) is in the snapshot but not enforced") + continue + fi + snap_c=$(jq -S -c "$NORM" "$snap") + live_c=$(jq -S -c . "$tmp/live-$rid.json") + if [ "$snap_c" != "$live_c" ]; then + drift+=("$snap: ruleset $rid ($name) differs from the enforced state") + fi +done + +for rid in ${live_ids[@]+"${live_ids[@]}"}; do + found=0 + for s in ${seen[@]+"${seen[@]}"}; do + [ "$s" = "$rid" ] && { found=1; break; } + done + if [ "$found" -eq 0 ]; then + lname=$(jq -r '.name // ""' "$tmp/live-$rid.json") + drift+=("ruleset $rid ($lname) is enforced but has no snapshot") + fi +done + +for d in ${drift[@]+"${drift[@]}"}; do + echo "::error::$d" +done + +uniq_seen=$(printf '%s\n' ${seen[@]+"${seen[@]}"} | sort -u | grep -c . || true) +echo "rulesets enforced: ${#live_ids[@]}, snapshots: $uniq_seen, drift: ${#drift[@]}" +if [ ${#drift[@]} -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/scripts/ci/check-workflow-duplicate-keys.sh b/scripts/ci/check-workflow-duplicate-keys.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/test-issue-regressions.sh b/scripts/ci/test-issue-regressions.sh old mode 100644 new mode 100755 From 7ba321dc76e778aa634f03958db70cd604891004 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:21:18 +0000 Subject: [PATCH 3/3] chore(ci): restore file mode of two scripts changed by mistake Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- scripts/ci/check-workflow-duplicate-keys.sh | 0 scripts/ci/test-issue-regressions.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 scripts/ci/check-workflow-duplicate-keys.sh mode change 100755 => 100644 scripts/ci/test-issue-regressions.sh diff --git a/scripts/ci/check-workflow-duplicate-keys.sh b/scripts/ci/check-workflow-duplicate-keys.sh old mode 100755 new mode 100644 diff --git a/scripts/ci/test-issue-regressions.sh b/scripts/ci/test-issue-regressions.sh old mode 100755 new mode 100644