diff --git a/.gitea/workflows/generate-rules.yml b/.gitea/workflows/generate-rules.yml index 3e49608da..4366451ef 100644 --- a/.gitea/workflows/generate-rules.yml +++ b/.gitea/workflows/generate-rules.yml @@ -9,6 +9,7 @@ on: - main paths: - main.py + - scripts/sync_surge_full.sh - config.toml - config.json - .gitea/workflows/generate-rules.yml @@ -50,7 +51,10 @@ jobs: - name: Generate rules env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + UPSTREAM_REF: ${{ vars.UPSTREAM_REF }} run: | + UPSTREAM_REF="${UPSTREAM_REF:-master}" + bash scripts/sync_surge_full.sh if [ -f config.toml ]; then python3 main.py --config config.toml else diff --git a/.gitignore b/.gitignore index 0288bb083..68c467f7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc config.toml +upstream/ diff --git a/README.md b/README.md index e7e45ed26..5ca0f631e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ 一个最小可用的规则生成器: -- 数据源来自 **Gitea** 仓库 -- 输入按目录分类(默认读取 `rule/Surge//.list`) +- 先全量拉取上游 `ios_rule_script` 的 `Surge` 规则到本地缓存 +- 再在本地做合并去重并转换多渠道 +- 输入按目录分类(默认读取 `upstream/rule/Surge//...`) - 输出仅包含你要的两种格式: - `dist/surge/.list` - `dist/loon/.list` @@ -22,7 +23,7 @@ ## 快速开始 -仓库已内置可直接跑的默认配置文件 `config.json`(指向 `yuanzhen869/ios-rule-script-full` 全量源)。 +仓库已内置可直接跑的默认配置文件 `config.json`(本地源模式,默认读 `upstream/`)。 1. 复制配置: @@ -46,6 +47,12 @@ export GITEA_TOKEN='your-token' 如果数据源仓库是公开的(当前默认就是公开源),可以不设置 token。 默认配置会生成约 600+ 分类(当前为 667 个可直接生成的分类)。 +3.1 先同步 Surge 全量源到本地缓存: + +```bash +bash scripts/sync_surge_full.sh +``` + 4. 生成全部分类: ```bash @@ -89,6 +96,16 @@ USER-AGENT,*youtube* ## 转换规则说明 +### 基础合并策略(Surge) + +每个分类优先按下面三份源文件做合并去重(按顺序): + +1. `.list` +2. `_Domain.list` +3. `_Resolve.list` + +如果某分类不满足上述结构,会自动回退到 `_All.list` 等可用文件。 + - Surge 输出:保留源规则(去重、清理空白) - Loon 输出:基于 Surge 规则直接输出(同样去重、清理空白) - Clash 输出: @@ -123,6 +140,7 @@ USER-AGENT,*youtube* 3. 推送到 `main` 后会自动执行;也可在 Actions 页面手动触发 当前定时表达式是 `0 3 * * *`(UTC),对应北京时间(UTC+8)每天 `11:00`。 +工作流会先执行 `scripts/sync_surge_full.sh` 拉取上游 Surge 全量数据,再生成多渠道规则。 ### 发布到独立仓库/分支(已内置) diff --git a/config.example.json b/config.example.json index 944a20318..502d76882 100644 --- a/config.example.json +++ b/config.example.json @@ -7,6 +7,8 @@ "token_env": "GITEA_TOKEN" }, "source": { + "mode": "local", + "local_root": "upstream", "root": "rule/Surge", "filename_pattern": "{name}.list", "include_categories": [], diff --git a/config.example.toml b/config.example.toml index 10989368e..e58976aec 100644 --- a/config.example.toml +++ b/config.example.toml @@ -6,7 +6,11 @@ ref = "main" token_env = "GITEA_TOKEN" [source] -# Usually this is where Surge source rules are stored in your Gitea repo. +# mode=gitea: read source via Gitea API +# mode=local: read source from local filesystem cache (recommended with sync script) +mode = "local" +local_root = "upstream" +# Usually this is where Surge source rules are stored. root = "rule/Surge" filename_pattern = "{name}.list" include_categories = [] diff --git a/config.json b/config.json index 5bb16ce9f..5e408a5bb 100644 --- a/config.json +++ b/config.json @@ -9,676 +9,13 @@ "source": { "root": "rule/Surge", "filename_pattern": "{name}.list", - "include_categories": [ - "115", - "12306", - "1337x", - "17173", - "178", - "17zuoye", - "2KGames", - "360", - "36kr", - "3Type", - "3dm", - "4399", - "4Paradigm", - "4chan", - "51Job", - "51nod", - "56", - "58TongCheng", - "6JianFang", - "6park", - "8btc", - "9News", - "9to5", - "ABC", - "AFP", - "ALJazeera", - "AMD", - "AMP", - "AOL", - "APKCombo", - "ATTWatchTV", - "Abema", - "AbemaTV", - "AcFun", - "Accuweather", - "Acer", - "Acplay", - "Actalis", - "AdColony", - "AdGuardSDNSFilter", - "AddToAny", - "Addthis", - "Adidas", - "Adobe", - "AdobeActivation", - "Advertising", - "AdvertisingLite", - "AdvertisingMiTV", - "AdvertisingTest", - "Aerogard", - "Afdian", - "Agora", - "AiQiCha", - "AirChina", - "AirWick", - "Akamai", - "Ali213", - "AliPay", - "Alibaba", - "All4", - "Amazon", - "AmazonCN", - "AmazonIP", - "AmazonPrimeVideo", - "AmazonTrust", - "Americasvoice", - "AnTianKeJi", - "Anaconda", - "AnandTech", - "Android", - "Anime", - "Anjuke", - "Anonv", - "Anthropic", - "Antutu", - "Apifox", - "Apkpure", - "AppLovin", - "AppStore", - "Apple", - "AppleDaily", - "AppleDev", - "AppleFirmware", - "AppleHardware", - "AppleID", - "AppleMail", - "AppleMedia", - "AppleMusic", - "AppleNews", - "AppleProxy", - "AppleTV", - "Arphic", - "Asahi", - "AsianMedia", - "Atlassian", - "Atomdata", - "BBC", - "BMW", - "BOC", - "BOCOM", - "Bahamut", - "BaiDuTieBa", - "BaiFenDian", - "BaiShanYunKeJi", - "Baidu", - "BaoFengYingYin", - "BardAI", - "Battle", - "BeStore", - "Beats", - "BesTV", - "Bestbuy", - "BianFeng", - "BiliBili", - "BiliBiliIntl", - "Binance", - "Bing", - "Blizzard", - "BlockHttpDNS", - "Bloomberg", - "Blued", - "BoXun", - "Bootcss", - "BrightCove", - "BritboxUK", - "Buypass", - "ByteDance", - "CAS", - "CBS", - "CCB", - "CCTV", - "CEB", - "CETV", - "CGB", - "CHT", - "CIBN", - "CKJR", - "CMB", - "CNKI", - "CNN", - "CNNIC", - "CSDN", - "CWSeed", - "CableTV", - "CaiNiao", - "CaiXinChuanMei", - "Cake", - "Camera360", - "Canon", - "ChengTongWangPan", - "China", - "ChinaASN", - "ChinaDNS", - "ChinaIPs", - "ChinaIPsBGP", - "ChinaMax", - "ChinaMaxNoIP", - "ChinaMaxNoMedia", - "ChinaMedia", - "ChinaMobile", - "ChinaNews", - "ChinaNoMedia", - "ChinaTelecom", - "ChinaTest", - "ChinaUnicom", - "Chromecast", - "ChuangKeTie", - "ChunYou", - "Cisco", - "Civitai", - "Classic", - "Claude", - "Cloudflare", - "Cloudflarecn", - "Clubhouse", - "ClubhouseIP", - "Cnet", - "Collabora", - "Comodo", - "Contentful", - "Coolapk", - "Copilot", - "Crypto", - "Cryptocurrency", - "CyberTrust", - "DAZN", - "DMM", - "DNS", - "DaMai", - "Dailymail", - "Dailymotion", - "DanDanZan", - "Dandanplay", - "DangDang", - "Dedao", - "Deepin", - "Deezer", - "Dell", - "Developer", - "DiDi", - "DiLianWangLuo", - "DiSiFanShi", - "DiabloIII", - "DianCeWangKe", - "DigiCert", - "DigitalOcean", - "DingTalk", - "DingXiangYuan", - "Direct", - "Discord", - "DiscoveryPlus", - "Disney", - "Disqus", - "Docker", - "Domob", - "Dood", - "DouBan", - "DouYin", - "Douyu", - "Download", - "Dropbox", - "DtDNS", - "Dubox", - "Duckduckgo", - "DuoWan", - "Duolingo", - "DynDNS", - "Dynu", - "EA", - "EHGallery", - "EastMoney", - "EasyPrivacy", - "Electron", - "Eleme", - "Embl", - "Emby", - "Emojipedia", - "EncoreTVB", - "Entrust", - "Epic", - "Espn", - "FOXNOW", - "FOXPlus", - "Facebook", - "FanFou", - "FangZhengDianZi", - "Faronics", - "FeiZhu", - "FengHuangWang", - "FengXiaWangLuo", - "Figma", - "Fiio", - "FindMy", - "FitnessPlus", - "FlipBoard", - "Flurry", - "Fox", - "FreeCodeCamp", - "FuboTV", - "Funshion", - "Game", - "GaoDe", - "Garena", - "Geely", - "Gemini", - "Gettyimages", - "Gigabyte", - "GitBook", - "GitHub", - "GitLab", - "Gitee", - "Global", - "GlobalMedia", - "GlobalScholar", - "GlobalSign", - "Gog", - "Google", - "GoogleDrive", - "GoogleEarth", - "GoogleFCM", - "GoogleSearch", - "GoogleVoice", - "GovCN", - "Gucci", - "GuiGuDongLi", - "HBO", - "HBOAsia", - "HBOHK", - "HBOUSA", - "HKBN", - "HKOpenTV", - "HKedcity", - "HP", - "HWTV", - "HaiNanHangKong", - "HamiVideo", - "HanYi", - "HashiCorp", - "Haveibeenpwned", - "HeMa", - "Hearthstone", - "HeroesoftheStorm", - "Heroku", - "HibyMusic", - "Hijacking", - "Himalaya", - "Hkgolden", - "HoYoverse", - "Hpplay", - "HuYa", - "HuaShuTV", - "HuanJu", - "Huawei", - "Huffpost", - "Hulu", - "HuluJP", - "HuluUSA", - "HunanTV", - "Hupu", - "IBM", - "ICBC", - "IKEA", - "IMDB", - "IPTVMainland", - "IPTVOther", - "ITV", - "Identrust", - "Imgur", - "Instagram", - "Intel", - "Intercom", - "JOOX", - "Japonx", - "Jetbrains", - "Jfrog", - "JiGuangTuiSong", - "JianGuoYun", - "JianShu", - "JinJiangWenXue", - "JingDong", - "Jquery", - "Jsdelivr", - "JueJin", - "Jwplayer", - "KKBOX", - "KKTV", - "KakaoTalk", - "Kantv", - "Keep", - "KingSmith", - "Kingsoft", - "KouDaiShiShang", - "Ku6", - "KuKeMusic", - "KuaiDi100", - "KuaiShou", - "KuangShi", - "KugouKuwo", - "LG", - "Lan", - "LanZouYun", - "LastFM", - "LastPass", - "LeJu", - "LeTV", - "Lenovo", - "LiTV", - "LianMeng", - "Limelight", - "Line", - "LineTV", - "Linguee", - "LinkedIn", - "Linux", - "LivePerson", - "Logitech", - "LondonReal", - "LuDaShi", - "LvMiLianChuang", - "MEGA", - "MIUIPrivacy", - "MOMOShop", - "MOOMusic", - "MOOV", - "Mail", - "Mailru", - "Majsoul", - "Manorama", - "Maocloud", - "Marketing", - "McDonalds", - "MeWatch", - "MeiTu", - "MeiTuan", - "MeiZu", - "MiWu", - "Microsoft", - "MicrosoftEdge", - "Migu", - "MingLueZhaoHui", - "Mogujie", - "Mojitianqi", - "Movefree", - "Mozilla", - "My5", - "NBC", - "NGA", - "NGAA", - "NTPService", - "NYPost", - "NYTimes", - "NaSDDNS", - "Naver", - "NaverTV", - "NetEase", - "NetEaseMusic", - "Netflix", - "Niconico", - "Nike", - "Nikkei", - "Nintendo", - "NivodTV", - "Notion", - "NowE", - "Npmjs", - "Nvidia", - "OKX", - "OP", - "OPPO", - "Olevod", - "OneDrive", - "OnePlus", - "OpenAI", - "Opera", - "Oracle", - "Oreilly", - "Origin", - "OuPeng", - "Overcast", - "Overwatch", - "PBS", - "PCCW", - "PChome", - "PChomeTW", - "PPTV", - "PSBC", - "Pandora", - "PandoraTV", - "ParamountPlus", - "Patreon", - "PayPal", - "Peacock", - "Picacg", - "Picsee", - "PikPak", - "Pinduoduo", - "PingAn", - "Pinterest", - "Pixiv", - "Pixnet", - "PlayStation", - "PotatoChat", - "PrimeVideo", - "Privacy", - "PrivateTracker", - "Protonmail", - "Proxy", - "ProxyLite", - "Pubmatic", - "Purikonejp", - "Python", - "QiNiuYun", - "QingCloud", - "Qobuz", - "Qualcomm", - "QuickConnect", - "Qyyjt", - "RTHK", - "Rakuten", - "Rarbg", - "Razer", - "Reabble", - "Reddit", - "Riot", - "Rockstar", - "RuanMei", - "SFExpress", - "SMG", - "SMZDM", - "STUN", - "Salesforce", - "Samsung", - "Scaleflex", - "Scholar", - "Sectigo", - "ShangHaiJuXiao", - "Shanling", - "Sharethis", - "ShenMa", - "ShiJiChaoXing", - "ShiNongZhiKe", - "Shopee", - "Shopify", - "Sina", - "Siri", - "SkyGO", - "Slack", - "SlideShare", - "Sling", - "SmarTone", - "Snap", - "Sohu", - "Sony", - "SouFang", - "SoundCloud", - "SourceForge", - "Spark", - "Speedtest", - "Spotify", - "Stackexchange", - "StarCraftII", - "Starbucks", - "Steam", - "SteamCN", - "Stripe", - "SuNing", - "SublimeText", - "SuiShiChuanMei", - "Supercell", - "Synology", - "SystemOTA", - "TCL", - "TIDAL", - "TVB", - "TVer", - "TaiKang", - "TaiWanGood", - "TaiheMusic", - "TapTap", - "TeamViewer", - "Teambition", - "Teams", - "Telegram", - "TelegramNL", - "TelegramSG", - "TelegramUS", - "Tencent", - "TencentVideo", - "TeraBox", - "Tesla", - "TestFlight", - "ThomsonReuters", - "Threads", - "TianTianKanKan", - "TianWeiChengXin", - "TianYaForum", - "TigerFintech", - "TikTok", - "Tmdb", - "TongCheng", - "TrustWave", - "TruthSocial", - "Tumblr", - "Twitch", - "Twitter", - "U17", - "UBI", - "UC", - "UCloud", - "UKMedia", - "UPYun", - "USMedia", - "Ubisoft", - "Ubuntu", - "Udacity", - "UnionPay", - "Unity", - "VISA", - "VK", - "VOA", - "Vancl", - "Vercel", - "Verisign", - "Verizon", - "VidolTV", - "VikACG", - "Viki", - "Vimeo", - "VipShop", - "ViuTV", - "Vivo", - "Voxmedia", - "W3schools", - "WIX", - "WanKaHuanJu", - "WanMeiShiJie", - "Wanfang", - "WangSuKeJi", - "WangXinKeJi", - "WeChat", - "WeTV", - "WeType", - "WeiZhiYunDong", - "Weibo", - "WenJuanXing", - "Westerndigital", - "Whatsapp", - "WiFiMaster", - "Wikimedia", - "Wikipedia", - "WildRift", - "WoLai", - "Wordpress", - "WorldofWarcraft", - "Wteam", - "Xbox", - "XiamiMusic", - "XianYu", - "XiaoGouKeJi", - "XiaoHongShu", - "XiaoMi", - "XiaoYuanKeJi", - "XieCheng", - "XingKongWuXian", - "XueErSi", - "XueQiu", - "Xunlei", - "YYeTs", - "Yandex", - "YiChe", - "YiXiaKeJi", - "YiZhiBo", - "YouMengChuangXiang", - "YouTube", - "YouTubeMusic", - "YouZan", - "Youku", - "YuanFuDao", - "YunFanJiaSu", - "ZDNS", - "Zalo", - "Zee", - "ZeeTV", - "Zendesk", - "ZhangYue", - "ZhiYinManKe", - "ZhiYunZhong", - "Zhihu", - "ZhihuAds", - "ZhongGuoShiHua", - "ZhongWeiShiJi", - "ZhongXingTongXun", - "ZhongYuanYiShang", - "ZhuanZhuan", - "Zoho", - "aiXcoder", - "eBay", - "friDay", - "iCloud", - "iCloudPrivateRelay", - "iFlytek", - "iQIYI", - "iQIYIIntl", - "iTalkBB", - "ifanr", - "myTVSUPER", - "zhanqi" + "include_categories": [], + "exclude_categories": [ + "Cloud", + "Assassin'sCreed" ], - "exclude_categories": [] + "mode": "local", + "local_root": "upstream" }, "output": { "dir": "dist", diff --git a/main.py b/main.py index 552057b23..16fbb84ba 100644 --- a/main.py +++ b/main.py @@ -38,6 +38,8 @@ class Config: repo: str ref: str token: str | None + source_mode: str + local_source_root: str source_root: str source_filename_pattern: str output_dir: str @@ -150,6 +152,8 @@ def load_config(path: Path) -> Config: repo=gitea["repo"], ref=gitea.get("ref", "main"), token=token, + source_mode=source.get("mode", "gitea"), + local_source_root=source.get("local_root", "."), source_root=source.get("root", "rule/Surge"), source_filename_pattern=source.get("filename_pattern", "{name}.list"), output_dir=output.get("dir", "dist"), @@ -173,6 +177,19 @@ def parse_rules(content: str) -> list[RuleLine]: continue seen.add(line) + # Domain-only files (e.g. *_Domain.list) may contain plain host suffixes + # without a rule prefix. Normalize them to DOMAIN-SUFFIX. + if "," not in line: + domain = line.lstrip(".").strip() + if not domain: + continue + normalized = f"DOMAIN-SUFFIX,{domain}" + if normalized in seen: + continue + seen.add(normalized) + rules.append(RuleLine(raw=normalized, rule_type="DOMAIN-SUFFIX")) + continue + parts = [part.strip() for part in line.split(",") if part.strip()] if not parts: continue @@ -301,11 +318,39 @@ def should_include_category(name: str, cfg: Config, cli_names: set[str]) -> bool return True +def local_abs_path(cfg: Config, relative_path: str) -> Path: + return Path(cfg.local_source_root).expanduser().resolve() / relative_path + + +def list_dir_source(client: GiteaClient, cfg: Config, path: str) -> list[dict[str, Any]]: + if cfg.source_mode == "local": + base = local_abs_path(cfg, path) + if not base.is_dir(): + raise RuntimeError(f"Local source path is not a directory: {base}") + entries: list[dict[str, Any]] = [] + for p in base.iterdir(): + entry_type = "dir" if p.is_dir() else "file" + entries.append({"name": p.name, "type": entry_type}) + return entries + + return client.list_dir(cfg.owner, cfg.repo, path, cfg.ref) + + +def read_source_file(client: GiteaClient, cfg: Config, path: str) -> str: + if cfg.source_mode == "local": + local_path = local_abs_path(cfg, path) + if not local_path.is_file(): + raise FileNotFoundError(str(local_path)) + return local_path.read_text(encoding="utf-8", errors="replace") + + return client.read_file(cfg.owner, cfg.repo, path, cfg.ref) + + def find_categories(client: GiteaClient, cfg: Config, cli_names: set[str]) -> list[str]: if cfg.include_categories: return sorted([n for n in cfg.include_categories if should_include_category(n, cfg, cli_names)]) - entries = client.list_dir(cfg.owner, cfg.repo, cfg.source_root, cfg.ref) + entries = list_dir_source(client, cfg, cfg.source_root) categories: list[str] = [] for entry in entries: @@ -327,37 +372,62 @@ def find_categories(client: GiteaClient, cfg: Config, cli_names: set[str]) -> li return sorted(categories) +def read_file_optional(client: GiteaClient, cfg: Config, candidate_paths: list[str]) -> tuple[str | None, str | None]: + for path in candidate_paths: + try: + return path, read_source_file(client, cfg, path) + except Exception: + continue + return None, None + + def build_one_category(client: GiteaClient, cfg: Config, name: str, base_out: Path) -> tuple[int, int, int, int]: filename_base = cfg.source_filename_pattern.format(name=name) - candidate_filenames = [ + # Preferred merge model: + # 1) .list (keyword/ua/ip with no-resolve) + # 2) _Domain.list (domain rules) + # 3) _Resolve.list (keyword/ua/ip without no-resolve) + # Merge then dedupe. + merge_filenames = [ filename_base, - f"{name}_All.list", f"{name}_Domain.list", f"{name}_Resolve.list", ] - candidate_paths: list[str] = [] - for fn in candidate_filenames: - candidate_paths.append(f"{cfg.source_root}/{name}/{fn}") # nested - candidate_paths.append(f"{cfg.source_root}/{fn}") # flat + merged_chunks: list[str] = [] + merged_sources: list[str] = [] + for fn in merge_filenames: + nested = f"{cfg.source_root}/{name}/{fn}" + flat = f"{cfg.source_root}/{fn}" + src_path, src_content = read_file_optional(client, cfg, [nested, flat]) + if src_path and src_content is not None: + merged_sources.append(src_path) + merged_chunks.append(src_content) - source_rel_path = "" - source_content = "" - last_error: Exception | None = None - for path in candidate_paths: - try: - source_content = client.read_file(cfg.owner, cfg.repo, path, cfg.ref) - source_rel_path = path - break - except Exception as exc: - last_error = exc - - if not source_rel_path: - if last_error is not None: - raise last_error - raise RuntimeError(f"unable to locate source list for category: {name}") - - rules = parse_rules(source_content) + if merged_chunks: + source_rel_path = " + ".join(merged_sources) + rules = parse_rules("\n".join(merged_chunks)) + else: + # Fallback for categories that only provide *_All.list or other variants. + fallback_filenames = [ + f"{name}_All.list", + f"{name}_Domain.list", + f"{name}_Resolve.list", + filename_base, + ] + source_rel_path = "" + source_content = "" + for fn in fallback_filenames: + nested = f"{cfg.source_root}/{name}/{fn}" + flat = f"{cfg.source_root}/{fn}" + src_path, src_content = read_file_optional(client, cfg, [nested, flat]) + if src_path and src_content is not None: + source_rel_path = src_path + source_content = src_content + break + if not source_rel_path: + raise RuntimeError(f"unable to locate source list for category: {name}") + rules = parse_rules(source_content) surge_out = base_out / "surge" / f"{name}.list" loon_out = base_out / "loon" / f"{name}.list" diff --git a/scripts/sync_surge_full.sh b/scripts/sync_surge_full.sh new file mode 100755 index 000000000..39d53e1d3 --- /dev/null +++ b/scripts/sync_surge_full.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +UPSTREAM_REPO_URL="${UPSTREAM_REPO_URL:-https://github.com/blackmatrix7/ios_rule_script.git}" +UPSTREAM_REF="${UPSTREAM_REF:-master}" +TARGET_DIR="${ROOT_DIR}/upstream" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +echo "[sync] clone upstream: $UPSTREAM_REPO_URL@$UPSTREAM_REF" +git clone --depth=1 --branch "$UPSTREAM_REF" "$UPSTREAM_REPO_URL" "$TMP_DIR/upstream" >/dev/null 2>&1 + +mkdir -p "$TARGET_DIR/rule" +rm -rf "$TARGET_DIR/rule/Surge" +cp -R "$TMP_DIR/upstream/rule/Surge" "$TARGET_DIR/rule/Surge" + +UPSTREAM_COMMIT="$(git -C "$TMP_DIR/upstream" rev-parse HEAD)" +UPSTREAM_DATE="$(git -C "$TMP_DIR/upstream" show -s --date=iso --format=%cd HEAD)" + +cat > "$TARGET_DIR/README.md" <