diff --git a/.github/scripts/bootstrap-dependabot.sh b/.github/scripts/bootstrap-dependabot.sh new file mode 100644 index 0000000..aeba136 --- /dev/null +++ b/.github/scripts/bootstrap-dependabot.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# Dispatch-only rollout of .github/dependabot.yml. Existing actions entries are untouched; +# other ecosystems/comments are preserved by the pure-append helper. No wrappers or labels. +# GH_TOKEN: PAT_WORKFLOWS with Contents: Read/write and branch-ruleset bypass for writes. +# REPOS_FILE: JSON array of non-archived repository names, supplied by the workflow. +# DRY_RUN defaults to true: GETs only, DRYRUN lines and a summary, no waits or git-data writes. +# SLEEP_CMD replaces the small inter-push delay in offline tests. +set -euo pipefail + +# Duplicated from bootstrap-common-workflows.yml's REPOS_TO_IGNORE so this independent, +# manually dispatched rollout does not change or execute the org-wide wrapper bootstrap. +repos_ignore='["Workflows","cratis.github.io","StudioIssues","Documentation","cratis.studio","Automation",".github","Dockerfiles","AI","Templates","Dockerfiles","release-action","Chronicle.Dapr","Chronicle.Wolverine","Stage","Studio","Direct","Ensemble","Infrastructure","Experiments","Strategy","Identity","KeldaKraft","studio-cluster-verification","Ante","Chronicle.Elixir","Components","Orleans","AI.Distribution","Arc.TypeScript","Chronicle.Python","Eventmodelers-Build-Kit-Java","Eventmodelers-Build-Kit-Kotlin","Chronicle.Go","Arc.Go","Fundamentals.Go"]' +# Wrapper-ignored repositories known to carry stale action pins: Dependabot only, not wrappers. +DEPENDABOT_INCLUDE='["Identity","Studio","Stage","Arc.TypeScript","Dockerfiles","AI","Infrastructure","Ensemble"]' +# Respect deliberate deletions, private Actions budgets and separately managed Go repositories. +DEPENDABOT_IGNORE='["Arc.Kotlin","Fundamentals.Kotlin","Arc.Elixir","acme-commerce-demo","SEO","Chronicle-ghsa-8c5g-wcfc-wgfh","Chronicle.Go","Arc.Go","Fundamentals.Go"]' +dry_run="${DRY_RUN:-true}" +case "$dry_run" in true|false) ;; *) echo 'DRY_RUN must be true or false' >&2; exit 2 ;; esac +repos_file="${REPOS_FILE:-${GITHUB_WORKSPACE:-.}/repos.json}" +helper="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ensure-dependabot-config.py" +jq -e 'type == "array" and all(.[]; type == "string")' "$repos_file" >/dev/null +repos=$(jq -r --argjson ignore "$repos_ignore" --argjson include "$DEPENDABOT_INCLUDE" \ + --argjson exclude "$DEPENDABOT_IGNORE" ' + .[] | select(. as $name | (($ignore | index($name)) == null or ($include | index($name)) != null) + and ($exclude | index($name)) == null)' "$repos_file") +input_file=$(mktemp) +config_file=$(mktemp) +trap 'rm -f "$input_file" "$config_file"' EXIT +records=() +skipped=() +pushes_done=0 +failures=0 + +# Minimal git-data helpers copied from the common bootstrap; never source its executable body. +extract_sha() { + local value + value=$(printf '%s' "$1" | jq -r "${2:-.sha} // empty" 2>/dev/null || true) + if [[ "$value" =~ ^[0-9a-f]{40,64}$ ]]; then printf '%s\n' "$value"; fi +} + +report() { + local repo="$1" action="$2" path="$3" line + [ "$dry_run" = true ] || return 0 + line="DRYRUN $repo $action $path" + echo "$line" + records+=("$line") + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then echo "$line" >> "$GITHUB_STEP_SUMMARY"; fi +} + +read_snapshot() { + local repo="$1" response + default_branch=$(gh api "repos/Cratis/$repo" --jq '.default_branch') || return 1 + [ -n "$default_branch" ] || return 1 + response=$(gh api "repos/Cratis/$repo/git/ref/heads/$default_branch") || return 1 + head_sha=$(extract_sha "$response" '.object.sha') + [ -n "$head_sha" ] || return 1 + response=$(gh api "repos/Cratis/$repo/git/commits/$head_sha") || return 1 + tree_sha=$(extract_sha "$response" '.tree.sha') + [ -n "$tree_sha" ] || return 1 + subtree=$(gh api "repos/Cratis/$repo/git/trees/$tree_sha?recursive=1") || return 1 + printf '%s' "$subtree" | jq -e '.truncated != true and (.tree | type == "array")' >/dev/null +} + +prepare_config() { + local repo="$1" existing_sha variant_sha content status=0 + variant_sha=$(printf '%s' "$subtree" | jq -r '.tree[] | select(.path == ".github/dependabot.yaml") | .sha') + if [ -n "$variant_sha" ]; then + echo " ⚠ $repo has dependabot.yaml; skipping dependabot.yml" + skipped+=("$repo .github/dependabot.yaml") + report "$repo" skip-exists .github/dependabot.yaml + return 10 + fi + existing_sha=$(printf '%s' "$subtree" | jq -r '.tree[] | select(.path == ".github/dependabot.yml") | .sha') + action=add + : > "$input_file" + if [ -n "$existing_sha" ]; then + action=update + content=$(gh api "repos/Cratis/$repo/git/blobs/$existing_sha" --jq .content) || return 1 + printf '%s' "$content" | base64 -d > "$input_file" || return 1 + fi + python3 "$helper" < "$input_file" > "$config_file" || status=$? + case "$status" in + 0) return 0 ;; + 10) + echo " ℹ $repo dependabot.yml already has github-actions" + report "$repo" skip-exists .github/dependabot.yml + return 10 + ;; + 11) + echo " ⚠ Cannot merge dependabot.yml in $repo safely, skipping" + skipped+=("$repo .github/dependabot.yml") + report "$repo" skip-unmergeable .github/dependabot.yml + return 10 + ;; + *) return 1 ;; + esac +} + +push_config() { + local repo="$1" response blob_sha new_tree_sha commit_sha updated_sha push_error error + response=$(gh api -X POST "repos/Cratis/$repo/git/blobs" \ + -f content="$(base64 < "$config_file" | tr -d '\n')" -f encoding=base64) || return 1 + blob_sha=$(extract_sha "$response") + [ -n "$blob_sha" ] || return 1 + response=$(jq -n --arg base "$tree_sha" --arg sha "$blob_sha" \ + '{base_tree: $base, tree: [{path: ".github/dependabot.yml", mode: "100644", type: "blob", sha: $sha}]}' \ + | gh api -X POST "repos/Cratis/$repo/git/trees" --input -) || return 1 + new_tree_sha=$(extract_sha "$response") + [ -n "$new_tree_sha" ] || return 1 + response=$(jq -n --arg tree "$new_tree_sha" --arg parent "$head_sha" \ + --arg message "Bootstrap Dependabot: $action dependabot.yml" \ + '{message: $message, tree: $tree, parents: [$parent]}' \ + | gh api -X POST "repos/Cratis/$repo/git/commits" --input -) || return 1 + commit_sha=$(extract_sha "$response") + [ -n "$commit_sha" ] || return 1 + push_error=$(mktemp) + if response=$(gh api -X PATCH "repos/Cratis/$repo/git/refs/heads/$default_branch" \ + -f sha="$commit_sha" -F force=false 2>"$push_error"); then + rm -f "$push_error" + updated_sha=$(extract_sha "$response" '.object.sha') + [ -n "$updated_sha" ] || return 1 + else + error=$(cat "$push_error") + rm -f "$push_error" + echo "$error" >&2 + if grep -Eqi 'not a fast forward|not fast forward|non-fast-forward' <<< "$error $response"; then return 75; fi + return 1 + fi + echo " ✓ Pushed to $default_branch in $repo: Bootstrap Dependabot: $action dependabot.yml" + pushed=true +} + +update_repo() { + local repo="$1" default_branch head_sha tree_sha subtree action status=0 permission + pushed=false + read_snapshot "$repo" || return 1 + prepare_config "$repo" || status=$? + case "$status" in 10) return 0 ;; 0) ;; *) return 1 ;; esac + if [ "$dry_run" = true ]; then + report "$repo" "$action" .github/dependabot.yml + return 0 + fi + permission=$(gh api "repos/Cratis/$repo" --jq '.permissions.push // false') || return 1 + if [ "$permission" != true ]; then + echo " ⚠ PAT_WORKFLOWS does not have write (push) access to Cratis/$repo" >&2 + return 1 + fi + push_config "$repo" +} + +summary() { + local record repo action path + if [ "$dry_run" = true ]; then + echo 'DRYRUN-SUMMARY' + printf '| Repository | Action | File |\n| --- | --- | --- |\n' + for record in "${records[@]}"; do + read -r _ repo action path <<< "$record" + printf '| %s | %s | %s |\n' "$repo" "$action" "$path" + done + fi + if [ "${#skipped[@]}" -gt 0 ]; then printf 'DEPENDABOT-SKIPPED %s\n' "${skipped[@]}"; fi + echo "Summary: $pushes_done commit(s), $failures failure(s)" +} + +while IFS= read -r repo; do + [ -n "$repo" ] || continue + echo "Processing Cratis/$repo..." + result=0 + update_repo "$repo" || result=$? + if [ "$result" -eq 75 ]; then + echo ' Branch moved; retrying once from fresh HEAD' + result=0 + update_repo "$repo" || result=$? + fi + if [ "$result" -ne 0 ]; then + echo " ⚠ Could not update Dependabot in $repo" >&2 + failures=$((failures + 1)) + continue + fi + if [ "$pushed" = true ]; then + pushes_done=$((pushes_done + 1)) + "${SLEEP_CMD:-sleep}" 1 + fi +done <<< "$repos" +summary +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then summary >> "$GITHUB_STEP_SUMMARY"; fi +[ "$failures" -eq 0 ] diff --git a/.github/scripts/ensure-dependabot-config.py b/.github/scripts/ensure-dependabot-config.py new file mode 100644 index 0000000..4500ac8 --- /dev/null +++ b/.github/scripts/ensure-dependabot-config.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +"""Pure-append Dependabot actions entry: 0 changed, 10 exists, 11 unsafe.""" +import re +import sys + +ENTRY = ''' - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 1 + groups: + actions: + patterns: + - "*" +''' +CANONICAL = 'version: 2\nupdates:\n' + ENTRY + + +def entry_indent(text): + """Accept only a final block-style updates sequence with consistent entries.""" + if re.search(r'[\t\r]|[&*][A-Za-z_]|<<\s*:', text): + return None + keys = [line for line in text.splitlines() if line and not line[0].isspace() and not line.startswith('#')] + if not keys or not re.fullmatch(r'updates:\s*(?:#.*)?', keys[-1]): + return None + if sum(bool(re.match(r'updates:', line)) for line in keys) != 1: + return None + body = text.split(keys[-1], 1)[1] + entries = re.findall(r'^( +)-\s+package-ecosystem:\s*\S+.*$', body, re.MULTILINE) + if not entries or len(set(entries)) != 1: + return None + indent = entries[0] + # Every sibling must be one of those ecosystem mappings; no flow-style entries. + siblings = [line for line in body.splitlines() if line.strip() and not line.lstrip().startswith('#') + and len(line) - len(line.lstrip()) <= len(indent)] + if len(siblings) != len(entries): + return None + return indent + + +def valid_yaml(text): + """Use PyYAML when already installed, but never require a third-party runtime.""" + try: + import yaml + except ImportError: + return True + try: + data = yaml.safe_load(text) + return isinstance(data, dict) and isinstance(data.get('updates'), list) + except yaml.YAMLError: + return False + + +def ensure(text): + if not text: + return 0, CANONICAL + if re.search(r'''package-ecosystem:\s*["']?github-actions(?:["']|\s|$)''', text): + return 10, '' + indent = entry_indent(text) + if indent is None: + return 11, '' + entry = '\n'.join(indent + line[2:] for line in ENTRY.splitlines()) + '\n' + merged = text + ('\n' if text.endswith('\n') else '\n\n') + entry + if not merged.startswith(text) or not valid_yaml(merged): + return 11, '' + return 0, merged + + +def main(): + # Reading bytes avoids universal-newline translation: CRLF must be rejected, not rewritten. + try: + text = sys.stdin.buffer.read().decode('utf-8') + except UnicodeDecodeError: + return 11 + status, output = ensure(text) + sys.stdout.write(output) + return status + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/scripts/tests/bootstrap-dependabot.test.py b/.github/scripts/tests/bootstrap-dependabot.test.py new file mode 100644 index 0000000..a1a86c9 --- /dev/null +++ b/.github/scripts/tests/bootstrap-dependabot.test.py @@ -0,0 +1,273 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +"""Offline tests for the dispatch-only, single-file Dependabot rollout.""" +import base64 +import hashlib +import importlib.util +import json +from pathlib import Path +import re +import subprocess +import sys +import unittest + +sys.dont_write_bytecode = True +SPEC = importlib.util.spec_from_file_location('rollout', Path(__file__).with_name('bootstrap-rollout.test.py')) +rollout = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(rollout) +SCRIPT = rollout.ROOT / '.github/scripts/bootstrap-dependabot.sh' +HELPER = rollout.ROOT / '.github/scripts/ensure-dependabot-config.py' +WORKFLOW = rollout.ROOT / '.github/workflows/bootstrap-dependabot.yml' +rollout.SCRIPT = SCRIPT +CANONICAL = '''version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 1 + groups: + actions: + patterns: + - "*" +''' +NUGET = '''# Stage.Axon dependencies: keep these comments and settings. +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: weekly + groups: + dependencies: + patterns: ["*"] +# trailing comment +''' + + +def merge(text, *options): + return subprocess.run(['python3', *options, str(HELPER)], input=text, text=True, + capture_output=True, timeout=10) + + +class MergeTests(unittest.TestCase): + def test_missing_is_exactly_canonical_even_without_optional_yaml(self): + for options in [(), ('-S',)]: + result = merge('', *options) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, CANONICAL) + + def test_append_preserves_original_bytes_and_is_idempotent(self): + for original in [NUGET, NUGET.rstrip('\n'), + NUGET.replace('\n ', '\n ')]: + with self.subTest(original=original): + result = merge(original) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith(original)) + self.assertEqual(result.stdout.count('package-ecosystem: "github-actions"'), 1) + self.assertTrue(result.stdout.endswith('\n')) + self.assertEqual(merge(result.stdout).returncode, 10) + result = merge(NUGET, '-S') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith(NUGET)) + + @unittest.skipUnless(importlib.util.find_spec('yaml'), 'optional PyYAML validation') + def test_available_yaml_parser_rejects_invalid_nested_yaml(self): + result = merge(NUGET.replace('interval: weekly', 'interval: [')) + self.assertEqual(result.returncode, 11, result.stderr) + self.assertEqual(result.stdout, '') + + def test_existing_actions_in_either_quoting_or_two_entries_is_untouched(self): + for ecosystem in ['github-actions', '"github-actions"', "'github-actions'"]: + original = NUGET + '\n - package-ecosystem: ' + ecosystem + '\n directory: "/"\n' + result = merge(original) + self.assertEqual(result.returncode, 10, result.stderr) + self.assertEqual(result.stdout, '') + + def test_unsafe_yaml_never_emits_a_replacement(self): + for original in ['version: 2\nupdates: []\n', 'version: 2\n', + NUGET + 'enable-beta-ecosystems: true\n', NUGET.replace('\n', '\r\n'), + NUGET.replace(' - ', '\t- '), NUGET.replace('updates:', 'updates: &entries'), + NUGET + ' - <<: *other\n', NUGET + ' - package-ecosystem: npm\n', + 'version: 2\nupdates:\n # no entries\n']: + with self.subTest(original=original): + result = merge(original) + self.assertEqual(result.returncode, 11, result.stderr) + self.assertEqual(result.stdout, '') + + +class BootstrapDependabotTests(unittest.TestCase): + initialize = rollout.BootstrapRolloutTests.initialize + run_bootstrap = rollout.BootstrapRolloutTests.run_bootstrap + message = rollout.BootstrapRolloutTests.message + + def setUp(self): + rollout.BootstrapRolloutTests.setUp(self) + self.env['DRY_RUN'] = 'false' + # Use production scope defaults unless a test deliberately overrides them. + for key in ['REPOS_IGNORE', 'DEPENDABOT_INCLUDE', 'DEPENDABOT_IGNORE', 'DRY_RUN_FILES']: + self.env.pop(key, None) + + def seed(self, repo, text, path='.github/dependabot.yml'): + state = json.loads(self.state_file.read_text()) + target = state['repos'][repo] + data = text.encode() + sha = hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\0' + data).hexdigest() + target['blobs'][sha] = base64.b64encode(data).decode() + target['trees']['b' * 40].append({'path': path, 'mode': '100644', 'type': 'blob', 'sha': sha}) + self.state_file.write_text(json.dumps(state)) + + def files(self, repo): + target = self.state['repos'][repo] + tree = target['commits'][target['head']]['tree']['sha'] + return {entry['path']: base64.b64decode(target['blobs'][entry['sha']]).decode() + for entry in target['trees'][tree]} + + def test_create_append_existing_and_second_run(self): + names = ['Fundamentals', 'Stage.Axon', 'Prompter', 'TwoEntries'] + self.initialize(names) + self.seed('Stage.Axon', NUGET) + self.seed('Prompter', CANONICAL) + self.seed('TwoEntries', NUGET + CANONICAL.split('updates:\n')[1]) + self.run_bootstrap(names) + self.assertEqual(self.files('Fundamentals'), {'.github/dependabot.yml': CANONICAL}) + self.assertEqual(self.message('Fundamentals'), 'Bootstrap Dependabot: add dependabot.yml') + self.assertTrue(self.files('Stage.Axon')['.github/dependabot.yml'].startswith(NUGET)) + self.assertEqual(self.message('Stage.Axon'), 'Bootstrap Dependabot: update dependabot.yml') + self.assertEqual(self.files('Prompter')['.github/dependabot.yml'], CANONICAL) + for name in ['Prompter', 'TwoEntries']: + self.assertEqual(self.message(name), 'Initial') + before = {name: self.state['repos'][name]['head'] for name in names} + self.run_bootstrap(names) + self.assertEqual(before, {name: self.state['repos'][name]['head'] for name in names}) + for name in names: + self.assertNotIn(rollout.SKIP_CI.strip(), self.message(name)) + self.assertFalse(any('/actions/' in ' '.join(call) or '/labels' in ' '.join(call) + for call in self.state['calls'])) + + def test_variant_and_unmergeable_are_skipped_without_any_writes(self): + names = ['Variant', 'Unsafe'] + self.initialize(names) + self.seed('Variant', CANONICAL, '.github/dependabot.yaml') + self.seed('Unsafe', 'version: 2\nupdates: []\n') + output = self.run_bootstrap(names) + self.assertNotIn('.github/dependabot.yml', self.files('Variant')) + self.assertEqual(self.files('Unsafe')['.github/dependabot.yml'], 'version: 2\nupdates: []\n') + self.assertIn('Cannot merge dependabot.yml in Unsafe safely, skipping', output) + self.assertIn('Unsafe', output.split('DEPENDABOT-SKIPPED')[-1]) + self.assertFalse(any('-X' in call for call in self.state['calls'])) + + def test_scope_includes_exceptions_without_touching_other_files(self): + included = ['Identity', 'Studio', 'Stage', 'Arc.TypeScript', 'Dockerfiles', 'AI', 'Infrastructure', 'Ensemble'] + excluded = ['Arc.Kotlin', 'Fundamentals.Kotlin', 'Arc.Elixir', 'acme-commerce-demo', 'SEO', + 'Chronicle-ghsa-8c5g-wcfc-wgfh', 'Chronicle.Go', 'Arc.Go', 'Fundamentals.Go', + 'Workflows', 'Chronicle.Python', 'Direct', 'Templates'] + names = included + excluded + self.initialize(names) + self.seed('Identity', NUGET) + self.seed('Stage', 'name: Custom workflow\n', '.github/workflows/update-packages.yml') + self.run_bootstrap(names) + for name in included: + self.assertIn('.github/dependabot.yml', self.files(name)) + self.assertEqual(self.files('Stage')['.github/workflows/update-packages.yml'], 'name: Custom workflow\n') + for name in excluded: + self.assertEqual(self.files(name), {}) + self.assertFalse(any(call[0] == name for call in self.state['calls'])) + + def test_dry_run_is_read_only_and_reports_exact_actions_to_stdout_and_summary(self): + names = ['Create', 'Update', 'Exists', 'Unsafe', 'Variant', 'Workflows'] + self.initialize(names) + self.seed('Update', NUGET) + self.seed('Exists', CANONICAL) + self.seed('Unsafe', 'version: 2\nupdates: []\n') + self.seed('Variant', CANONICAL, '.github/dependabot.yaml') + summary = self.directory / 'summary.md' + output = self.run_bootstrap(names, DRY_RUN='true', GITHUB_STEP_SUMMARY=str(summary)) + expected = ['DRYRUN Create add .github/dependabot.yml', + 'DRYRUN Update update .github/dependabot.yml', + 'DRYRUN Exists skip-exists .github/dependabot.yml', + 'DRYRUN Unsafe skip-unmergeable .github/dependabot.yml', + 'DRYRUN Variant skip-exists .github/dependabot.yaml'] + self.assertEqual([line for line in output.splitlines() if line.startswith('DRYRUN ')], expected) + self.assertEqual([line for line in summary.read_text().splitlines() if line.startswith('DRYRUN ')], expected) + self.assertIn('DRYRUN-SUMMARY', output) + self.assertFalse(any('-X' in call for call in self.state['calls']), self.state['calls']) + self.assertEqual(self.state['events'], []) + self.assertTrue(all(repo['head'] == 'a' * 40 for repo in self.state['repos'].values())) + + def test_script_defaults_to_dry_run_and_rejects_invalid_dry_run(self): + self.initialize(['Create']) + self.env.pop('DRY_RUN') + output = self.run_bootstrap(['Create']) + self.assertIn('DRYRUN Create add .github/dependabot.yml', output) + self.assertFalse(any('-X' in call for call in self.state['calls'])) + self.run_bootstrap(['Create'], expected=2, DRY_RUN='typo') + + def test_no_eligible_repositories_is_a_successful_read_only_noop(self): + self.initialize(['Workflows']) + output = self.run_bootstrap(['Workflows'], DRY_RUN='true') + self.assertIn('Summary: 0 commit(s), 0 failure(s)', output) + self.assertEqual(self.state['calls'], []) + self.run_bootstrap([], DRY_RUN='true') + self.assertEqual(self.state['calls'], []) + + def test_invalid_repository_file_fails_before_api_calls(self): + self.initialize(['Create']) + for invalid in [{'name': 'Create'}, [42]]: + self.run_bootstrap(invalid, expected=1) + self.assertEqual(self.state['calls'], []) + + def test_branch_movement_retries_once_using_fresh_head(self): + self.initialize(['Create']) + fake = self.directory / 'gh' + fake.write_text(rollout.FAKE.replace( + " if r['commits'][fields['sha']]['parents'] != [r['head']]:", + " if not s.get('moved'):\n" + " s['moved'] = True\n" + " r['commits']['c'*40] = r['commits'][r['head']]\n" + " r['head'] = 'c'*40\n" + " if r['commits'][fields['sha']]['parents'] != [r['head']]:")) + output = self.run_bootstrap(['Create']) + self.assertIn('Branch moved; retrying once from fresh HEAD', output) + self.assertEqual(self.files('Create'), {'.github/dependabot.yml': CANONICAL}) + self.assertEqual(len([call for call in self.state['calls'] if 'PATCH' in call]), 2) + + def test_failed_blob_creates_no_tree_commit_or_ref_and_fails_run(self): + self.initialize(['Create']) + fake = self.directory / 'gh' + fake.write_text(rollout.FAKE.replace(" result = {'sha':sha}\nelif method == 'POST' and endpoint.endswith('/git/trees'):", + " error = 'HTTP 403 blob rejected'\nelif method == 'POST' and endpoint.endswith('/git/trees'):")) + self.run_bootstrap(['Create'], expected=1) + writes = [call for call in self.state['calls'] if '-X' in call] + self.assertEqual(len(writes), 1) + self.assertTrue(any(endpoint.endswith('/git/blobs') for endpoint in writes[0])) + + +class WorkflowTests(unittest.TestCase): + def test_rollout_is_dispatch_only_and_dry_by_default(self): + source = WORKFLOW.read_text() + events = re.search(r'^on:\n(.*?)(?=^[^ #\n])', source, re.M | re.S)[1] + self.assertEqual(re.findall(r'^ ([a-z_]+):', events, re.M), ['workflow_dispatch']) + self.assertIn(' default: true', events) + self.assertIn(' type: boolean', events) + self.assertIn('permissions:\n contents: read', source) + self.assertIn('timeout-minutes:', source) + self.assertIn('cancel-in-progress: false', source) + self.assertIn('GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }}', source) + self.assertIn('gh repo list Cratis --limit 1000 --json name,isArchived', source) + self.assertIn('select(.isArchived == false)', source) + self.assertIn('DRY_RUN: ${{ inputs.dry_run }}', source) + self.assertIn('bash .github/scripts/bootstrap-dependabot.sh', source) + self.assertRegex(source, r'uses: actions/checkout@[0-9a-f]{40} # v7\.0\.1') + self.assertIn('persist-credentials: false', source) + + def test_verification_paths_only_cover_the_standalone_rollout(self): + source = WORKFLOW.with_name('verify-bootstrap-dependabot.yml').read_text() + self.assertNotIn('bootstrap-common-workflows', source) + for name in ['bootstrap-dependabot.sh', 'ensure-dependabot-config.py', 'bootstrap-dependabot.test.py', + 'bootstrap-dependabot.yml', 'verify-bootstrap-dependabot.yml']: + self.assertIn(name, source) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/.github/workflows/bootstrap-dependabot.yml b/.github/workflows/bootstrap-dependabot.yml new file mode 100644 index 0000000..821615c --- /dev/null +++ b/.github/workflows/bootstrap-dependabot.yml @@ -0,0 +1,61 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +name: Bootstrap Dependabot +# Manual, single-file rollout. Merging this workflow NEVER starts an organization-wide write. +# Appends monthly grouped github-actions updates without replacing existing ecosystems or comments. +# PAT_WORKFLOWS needs Contents: Read/write and ruleset bypass for direct default-branch pushes. + +on: + workflow_dispatch: + inputs: + dry_run: + description: List changes without writing to repositories + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: bootstrap-dependabot + cancel-in-progress: false + +jobs: + bootstrap: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Verify PAT can authenticate + env: + GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} + run: | + auth_error=$(mktemp) + if ! gh api /user >/dev/null 2>"$auth_error"; then + echo "::error::Could not authenticate with PAT_WORKFLOWS. Is the secret set correctly?" + echo "::error::API error: $(cat "$auth_error")" + rm -f "$auth_error" + exit 1 + fi + rm -f "$auth_error" + login=$(gh api /user --jq '.login') + echo "✓ Authenticated as $login" + + - name: Get non-archived Cratis repositories + env: + GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} + run: | + gh repo list Cratis --limit 1000 --json name,isArchived \ + --jq '[.[] | select(.isArchived == false) | .name]' \ + > "$GITHUB_WORKSPACE/repos.json" + + - name: Bootstrap Dependabot configuration + env: + GH_TOKEN: ${{ secrets.PAT_WORKFLOWS }} + REPOS_FILE: ${{ github.workspace }}/repos.json + DRY_RUN: ${{ inputs.dry_run }} + run: bash .github/scripts/bootstrap-dependabot.sh diff --git a/.github/workflows/verify-bootstrap-dependabot.yml b/.github/workflows/verify-bootstrap-dependabot.yml new file mode 100644 index 0000000..9412207 --- /dev/null +++ b/.github/workflows/verify-bootstrap-dependabot.yml @@ -0,0 +1,39 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +name: Verify Bootstrap Dependabot + +on: + pull_request: + paths: + - .github/scripts/bootstrap-dependabot.sh + - .github/scripts/ensure-dependabot-config.py + - .github/scripts/tests/bootstrap-dependabot.test.py + - .github/workflows/bootstrap-dependabot.yml + - .github/workflows/verify-bootstrap-dependabot.yml + push: + branches: [main] + paths: + - .github/scripts/bootstrap-dependabot.sh + - .github/scripts/ensure-dependabot-config.py + - .github/scripts/tests/bootstrap-dependabot.test.py + - .github/workflows/bootstrap-dependabot.yml + - .github/workflows/verify-bootstrap-dependabot.yml + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Verify Dependabot append, scope and read-only dry runs + # Stateful fake gh only: no token, GitHub API calls or containers. + run: python3 .github/scripts/tests/bootstrap-dependabot.test.py