diff --git a/.github/scripts/bootstrap-common-workflows.sh b/.github/scripts/bootstrap-common-workflows.sh index dfc1f7e..26a3be1 100644 --- a/.github/scripts/bootstrap-common-workflows.sh +++ b/.github/scripts/bootstrap-common-workflows.sh @@ -75,19 +75,20 @@ declare -A BOOTSTRAPPED_FILES # secrets: inherit BOOTSTRAPPED_FILES[".github/workflows/cleanup-pr-artifacts.yml"]="bmFtZTogQ2xlYW51cCBQUiBBcnRpZmFjdHMKCm9uOgogIHB1bGxfcmVxdWVzdDoKICAgIHR5cGVzOiBbY2xvc2VkXQoKam9iczoKICBjbGVhbnVwOgogICAgdXNlczogQ3JhdGlzL1dvcmtmbG93cy8uZ2l0aHViL3dvcmtmbG93cy9jbGVhbnVwLXByLWFydGlmYWN0cy55bWxAbWFpbgogICAgd2l0aDoKICAgICAgcHVsbF9yZXF1ZXN0OiAke3sgZ2l0aHViLmV2ZW50LnB1bGxfcmVxdWVzdC5udW1iZXIgfX0KICAgIHNlY3JldHM6IGluaGVyaXQK" -# update-packages.yml — nightly scheduled + manual trigger, delegates to reusable workflow +# update-packages.yml — weekly scheduled + manual trigger, delegates to reusable workflow +# The cron placeholder is replaced per repository before creating its blob. # Decodes to: # name: Update Packages # on: # schedule: -# - cron: '0 6 * * *' +# - cron: '__PACKAGE_UPDATE_CRON__' # workflow_dispatch: # jobs: # update: # uses: Cratis/Workflows/.github/workflows/update-packages.yml@main # secrets: # PAT_WORKFLOWS: ${{ secrets.PAT_WORKFLOWS }} -BOOTSTRAPPED_FILES[".github/workflows/update-packages.yml"]="bmFtZTogVXBkYXRlIFBhY2thZ2VzCgpvbjoKICBzY2hlZHVsZToKICAgIC0gY3JvbjogJzAgNiAqICogKicKICB3b3JrZmxvd19kaXNwYXRjaDoKCmpvYnM6CiAgdXBkYXRlOgogICAgdXNlczogQ3JhdGlzL1dvcmtmbG93cy8uZ2l0aHViL3dvcmtmbG93cy91cGRhdGUtcGFja2FnZXMueW1sQG1haW4KICAgIHNlY3JldHM6CiAgICAgIFBBVF9XT1JLRkxPV1M6ICR7eyBzZWNyZXRzLlBBVF9XT1JLRkxPV1MgfX0K" +BOOTSTRAPPED_FILES[".github/workflows/update-packages.yml"]="bmFtZTogVXBkYXRlIFBhY2thZ2VzCgpvbjoKICBzY2hlZHVsZToKICAgIC0gY3JvbjogJ19fUEFDS0FHRV9VUERBVEVfQ1JPTl9fJwogIHdvcmtmbG93X2Rpc3BhdGNoOgoKam9iczoKICB1cGRhdGU6CiAgICB1c2VzOiBDcmF0aXMvV29ya2Zsb3dzLy5naXRodWIvd29ya2Zsb3dzL3VwZGF0ZS1wYWNrYWdlcy55bWxAbWFpbgogICAgc2VjcmV0czoKICAgICAgUEFUX1dPUktGTE9XUzogJHt7IHNlY3JldHMuUEFUX1dPUktGTE9XUyB9fQo=" # auto-approve-publish-deployments.yml — approves pending npm/nuget deployments # for Publish workflow runs (trusted publishing environments). @@ -230,6 +231,14 @@ BOOTSTRAPPED_FILES[".github/workflows/verify-semver-label.yml"]="bmFtZTogVmVyaWZ # id: ca1031 BOOTSTRAPPED_FILES[".github/codeql/codeql-config.yml"]="bmFtZTogIkNyYXRpcyBDb2RlUUwgY29uZmlnIgoKcXVlcnktZmlsdGVyczoKICAjIENBMTAzMSBpcyBpbnRlbnRpb25hbGx5IGV4Y2x1ZGVkIGZyb20gdGhlIHNoYXJlZCBiYXNlbGluZS4KICAtIGV4Y2x1ZGU6CiAgICAgIGlkOiBjYTEwMzEK" +# Stable POSIX checksum of the repository name: Monday, minute 1-59, 03:00-07:59 UTC. +# Unlike an array index, this does not move existing schedules when repositories are added. +package_update_cron() { + local checksum remainder + read -r checksum remainder < <(printf '%s' "$1" | cksum) + printf '%d %d * * 1' "$((checksum % 59 + 1))" "$((checksum / 59 % 5 + 3))" +} + # ================================================================ # Per-file skips # ================================================================ @@ -395,6 +404,11 @@ echo "$repos" | jq -r '.[]' | while read -r repo; do fi fi file_b64="${BOOTSTRAPPED_FILES[$file_path]}" + if [ "$file_path" = ".github/workflows/update-packages.yml" ]; then + file_content=$(printf '%s' "$file_b64" | base64 -d) + file_content="${file_content/__PACKAGE_UPDATE_CRON__/$(package_update_cron "$repo")}" + file_b64=$(printf '%s\n' "$file_content" | base64 | tr -d '\n') + fi case "$file_path" in .github/codeql/codeql-config.yml) file_label="codeql-config" diff --git a/.github/scripts/tests/bootstrap-schedules.test.py b/.github/scripts/tests/bootstrap-schedules.test.py new file mode 100644 index 0000000..2a5104e --- /dev/null +++ b/.github/scripts/tests/bootstrap-schedules.test.py @@ -0,0 +1,64 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +"""Offline checks of the bootstrap's repository-specific package-update wrapper.""" +import base64 +from pathlib import Path +import re +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[3] +SOURCE = (ROOT / ".github/scripts/bootstrap-common-workflows.sh").read_text() +TEMPLATE = re.search( + r'BOOTSTRAPPED_FILES\[".github/workflows/update-packages.yml"\]="([^"]+)"', SOURCE)[1] +CRON_FUNCTION = re.search(r"^package_update_cron\(\) \{.*?^\}", SOURCE, re.M | re.S)[0] +RENDER = re.search( + r'^ if \[ "\$file_path" = "\.github/workflows/update-packages.yml" \]; then\n.*?^ fi', + SOURCE, re.M | re.S)[0] + + +def render(repo): + # Execute the production rendering block without invoking the live bootstrap, + # its PAT probe, or any GitHub writes. + return subprocess.check_output([ + "bash", "-c", 'set -euo pipefail\n' + CRON_FUNCTION + '\n' + 'repo="$1"\nfile_b64="$2"\nfile_path=".github/workflows/update-packages.yml"\n' + + RENDER + '\nprintf "%s" "$file_b64"', "bootstrap-test", repo, TEMPLATE, + ], text=True) + + +class BootstrapSchedulesTests(unittest.TestCase): + def test_only_the_schedule_changes(self): + template = base64.b64decode(TEMPLATE).decode() + rendered = base64.b64decode(render("Arc")).decode() + cron = re.search(r"cron: '([^']+)'", rendered)[1] + self.assertEqual(rendered, template.replace("__PACKAGE_UPDATE_CRON__", cron)) + self.assertIn(" workflow_dispatch:", rendered) + self.assertNotIn("__PACKAGE_UPDATE_CRON__", rendered) + + def test_weekly_offsets_are_stable_and_off_the_hour(self): + crons = set() + for repo in ["Arc", "Chronicle", "Fundamentals", "cli", "Arc.TypeScript", "Cratis-Example"]: + first = render(repo) + self.assertEqual(first, render(repo)) + cron = re.search(r"cron: '([^']+)'", base64.b64decode(first).decode())[1] + minute, hour, day, month, weekday = cron.split() + self.assertTrue(1 <= int(minute) <= 59) + self.assertTrue(3 <= int(hour) <= 7) + self.assertEqual((day, month, weekday), ("*", "*", "1")) + crons.add(cron) + self.assertGreater(len(crons), 1) + + def test_private_callers_use_the_same_deterministic_offsets(self): + expected = { + "Direct": "37 5 * * 1", "Studio": "48 5 * * 1", + "Infrastructure": "5 3 * * 1", "Ensemble": "32 4 * * 1", + "Experiments": "57 5 * * 1", + } + for repo, cron in expected.items(): + with self.subTest(repo=repo): + self.assertIn("cron: '" + cron + "'", base64.b64decode(render(repo)).decode()) + + +if __name__ == "__main__": + unittest.main(verbosity=2)