From 2c06311e5a81819a28e36c30e5f1348e6f0c88f0 Mon Sep 17 00:00:00 2001 From: woksin Date: Sun, 25 Oct 2026 02:50:32 +0200 Subject: [PATCH] Create missing release-intent labels during workflow bootstrap --- .github/scripts/bootstrap-common-workflows.sh | 41 +++++++ .../scripts/tests/bootstrap-policy.test.py | 103 ++++++++++++++++++ README.md | 2 + 3 files changed, 146 insertions(+) diff --git a/.github/scripts/bootstrap-common-workflows.sh b/.github/scripts/bootstrap-common-workflows.sh index 1e3d53e..2e8a8cf 100644 --- a/.github/scripts/bootstrap-common-workflows.sh +++ b/.github/scripts/bootstrap-common-workflows.sh @@ -402,6 +402,44 @@ releases_with_release_action() { return 1 } +# Create missing release-intent labels only; existing names (case-insensitive), colors and descriptions stay intact. +ensure_release_intent_labels() { + local repo="$1" labels label_error label_response name color description + label_error=$(mktemp) + if ! labels=$(gh api --paginate "repos/Cratis/$repo/labels?per_page=100" \ + --jq '.[].name | ascii_downcase' 2>"$label_error"); then + echo " ⚠ Could not read release-intent labels for $repo, skipping" + echo " API error: $(cat "$label_error" 2>/dev/null || true)" + rm -f "$label_error" + return 1 + fi + rm -f "$label_error" + + while IFS='|' read -r name color description; do + if grep -Fxq "$name" <<<"$labels"; then continue; fi + label_error=$(mktemp) + if label_response=$(gh api -X POST "repos/Cratis/$repo/labels" \ + -f name="$name" -f color="$color" -f description="$description" 2>"$label_error"); then + echo " ✓ Created release-intent label $name in $repo" + elif grep -q 'HTTP 422' "$label_error" \ + && printf '%s' "$label_response" | jq -e 'any(.errors[]?; .code == "already_exists")' >/dev/null 2>&1; then + : # Another writer created the label after our read; never modify it. + else + echo " ⚠ Could not create release-intent label $name in $repo, skipping" + echo " API error: $(cat "$label_error" 2>/dev/null || true)" + rm -f "$label_error" + return 1 + fi + rm -f "$label_error" + done <<'LABELS' +major|bfdadc|Breaking change: merging releases a new major version +minor|e5db36|New functionality: merging releases a new minor version +patch|ace006|Fix: merging releases a new patch version +no-release|ededed|Merging publishes no package, image, release, or deployment +LABELS + echo " ✓ All four release-intent labels exist in $repo" +} + # ================================================================ # Rollout: canary first, then staggered [skip ci] commits (Cratis/Workflows#163) # ================================================================ @@ -618,6 +656,9 @@ update_repo() { fi # Arc.Kotlin retains its deliberately pinned semver wrapper, not a second gate. if skips_file "$SEMVER_LABEL_FILE" "$repo"; then repo_releases=false; fi + if [ "$repo_releases" = true ]; then + ensure_release_intent_labels "$repo" || return 1 + fi for file_path in "${!BOOTSTRAPPED_FILES[@]}"; do if skips_file "$file_path" "$repo"; then echo " ℹ Skipping $file_path (Cratis/$repo keeps its own copy)" diff --git a/.github/scripts/tests/bootstrap-policy.test.py b/.github/scripts/tests/bootstrap-policy.test.py index 7a5d55e..c80dc71 100644 --- a/.github/scripts/tests/bootstrap-policy.test.py +++ b/.github/scripts/tests/bootstrap-policy.test.py @@ -18,6 +18,12 @@ LEGACY = ['.github/workflows/' + name + '.yml' for name in ['verify-release-notes', 'verify-semver-label', 'verify-no-work-records']] PAYLOADS = dict(re.findall(r'BOOTSTRAPPED_FILES\["([^"]+)"\]="([^"]+)"', SOURCE)) +LABELS = [ + {'name': 'major', 'color': 'bfdadc', 'description': 'Breaking change: merging releases a new major version'}, + {'name': 'minor', 'color': 'e5db36', 'description': 'New functionality: merging releases a new minor version'}, + {'name': 'patch', 'color': 'ace006', 'description': 'Fix: merging releases a new patch version'}, + {'name': 'no-release', 'color': 'ededed', 'description': 'Merging publishes no package, image, release, or deployment'}, +] def blob(data): @@ -52,6 +58,21 @@ def tree_id(entries): elif method == 'GET' and endpoint.count('/') == 2: result = {'default_branch':'main','permissions':{'push':True},'private':s.get('private',False)} if s.get('fail_visibility') and '.private' in a: error = 'HTTP 503 repository read failed' +elif method == 'GET' and '/labels?' in endpoint: + pages = s.get('label_pages', [s.get('labels', [])]) + result = [label for page in (pages if '--paginate' in a else pages[:1]) for label in page] + if s.get('fail_labels'): error = 'HTTP 503 labels read failed' +elif method == 'POST' and endpoint.endswith('/labels'): + failure = s.get('label_errors', {}).get(fields['name']) + if failure: + status, code = failure + result = {'message':'Validation Failed','errors':[{'resource':'Label','field':'name','code':code}]} + error = 'gh: Validation Failed (HTTP '+str(status)+')' + if status == 422 and code == 'already_exists': + s.setdefault('labels', []).append({**fields, 'color':'123456','description':'Concurrent label'}) + else: + s.setdefault('labels', []).append(fields) + result = fields elif '/git/ref/heads/' in endpoint: result = {'object':{'sha':s['head']}} elif method == 'GET' and '/git/commits/' in endpoint: @@ -108,6 +129,7 @@ def tree_id(entries): error = 'UNEXPECTED API CALL '+repr(a) p.write_text(json.dumps(s)) if error: + if result is not None: print(json.dumps(result)) print(error,file=sys.stderr) sys.exit(1) if '--jq' in a: @@ -116,6 +138,8 @@ def tree_id(entries): elif q == '.default_branch': print(result['default_branch']) elif q == '.private': print('true' if result['private'] else 'false') elif q == '.content': print(result['content']) + elif q == '.[].name | ascii_downcase': + for label in result: print(label['name'].lower()) else: raise Exception(q) else: print(json.dumps(result)) @@ -300,6 +324,85 @@ def test_unknown_visibility_skips_the_repository(self): self.assertEqual(self.state['tree_writes'], []) self.assertEqual(self.state['patches'], 0) + def label_calls(self): + return [call for call in self.state['calls'] if any('/labels' in arg for arg in call)] + + def label_posts(self): + calls = self.label_calls() + self.assertFalse(any(method in call for call in calls for method in ['PATCH', 'DELETE']), calls) + return [dict(call[i+1].split('=', 1) for i, arg in enumerate(call) if arg == '-f') + for call in calls if 'POST' in call] + + def test_releasing_repository_creates_only_missing_labels_even_without_workflow_changes(self): + self.initialize(labels=[LABELS[0], LABELS[2]]) + out = self.run_bootstrap() + self.assertEqual(self.label_posts(), [LABELS[1], LABELS[3]]) + self.assertEqual(out.count('Created release-intent label '), 2) + self.assertEqual(out.count('All four release-intent labels exist'), 1) + self.state['labels'] = [LABELS[0]] + self.state['calls'] = [] + self.state_file.write_text(json.dumps(self.state)) + out = self.run_bootstrap() + self.assertEqual(self.label_posts(), LABELS[1:]) + self.assertIn('No changes needed', out) + self.assertEqual(len(self.state['tree_writes']), 1) + self.assertEqual(self.state['patches'], 1) + + def test_existing_labels_are_preserved_case_insensitively(self): + labels = [{**label, 'name': label['name'].capitalize(), 'color': '123456', + 'description': 'Repository-owned label'} for label in LABELS] + self.initialize(labels=labels) + out = self.run_bootstrap() + self.assertEqual(self.label_posts(), []) + self.assertEqual(self.state['labels'], labels) + self.assertNotIn('Created release-intent label ', out) + self.assertEqual(out.count('All four release-intent labels exist'), 1) + + def test_nonreleasing_repositories_make_no_label_api_calls(self): + for repo, files in [('Example', {}), ('Arc.Kotlin', {LEGACY[1]: base64.b64decode(PAYLOADS[LEGACY[1]]).decode()})]: + with self.subTest(repo=repo): + (self.directory / 'repos.json').write_text(json.dumps([repo])) + self.initialize(files) + self.run_bootstrap() + self.assertEqual(self.label_calls(), []) + + def test_label_read_failure_fails_the_repository_without_creating_anything(self): + self.initialize(labels=[LABELS[0]], fail_labels=True) + out = self.run_bootstrap(expected=1) + self.assertIn('⚠ Could not read release-intent labels for Example, skipping', out) + self.assertEqual(len(self.label_calls()), 1) + self.assertEqual(self.label_posts(), []) + self.assertEqual(self.state['labels'], [LABELS[0]]) + self.assertEqual(self.state['tree_writes'], []) + self.assertEqual(self.state['patches'], 0) + + def test_only_http_422_already_exists_is_tolerated_on_label_creation(self): + for status, code, expected in [(422, 'already_exists', 0), (422, 'invalid', 1), + (403, 'already_exists', 1), (503, 'unavailable', 1)]: + with self.subTest(status=status, code=code): + self.initialize(labels=LABELS[:3], label_errors={'no-release': [status, code]}) + out = self.run_bootstrap(expected=expected) + self.assertEqual(self.label_posts(), [LABELS[3]]) + self.assertNotIn('Created release-intent label ', out) + if expected == 0: + self.assertIn('All four release-intent labels exist', out) + self.assertEqual(self.state['labels'][-1]['description'], 'Concurrent label') + else: + self.assertIn('⚠ Could not create release-intent label no-release in Example, skipping', out) + self.assertNotIn('All four release-intent labels exist', out) + self.assertEqual(self.state['tree_writes'], []) + self.assertEqual(self.state['patches'], 0) + + def test_label_discovery_reads_both_pages_before_creating_labels(self): + first_page = [{'name': 'unrelated-'+str(i)} for i in range(98)] + LABELS[:2] + self.initialize(label_pages=[first_page, LABELS[2:]]) + self.run_bootstrap() + self.assertEqual(self.label_posts(), []) + calls = self.label_calls() + self.assertEqual(len(calls), 1) + self.assertIn('--paginate', calls[0]) + self.assertIn('repos/Cratis/Example/labels?per_page=100', calls[0]) + def test_known_hash_table_covers_every_historical_payload_per_path(self): history = subprocess.check_output(['git', 'log', '-p', 'HEAD', '--', '.github/scripts/bootstrap-common-workflows.sh'], cwd=ROOT, text=True) for path in LEGACY: diff --git a/README.md b/README.md index 85901d1..99ceb3f 100644 --- a/README.md +++ b/README.md @@ -888,6 +888,8 @@ that never release receive `release-intent: false`; Arc.Kotlin also receives fal because it retains its pinned semver caller. The canonical policy caller passes `runs-on: ${{ vars.RUNNER_GATE || 'ubuntu-latest' }}` to the gate. The bootstrap reads each repository's visibility and renders that default per repository: a public repository gets `ubuntu-latest` (its `pull_request` jobs never run on self-hosted runners, per `.cratis/ai/rules/github-actions.md`), a private repository gets `cratis-arc-small`, also for the Dependabot label normalizer, and a repository whose visibility cannot be read is skipped. `RUNNER_GATE` is an outage escape hatch, normally unset: a public repository sets it temporarily to reroute the job when the default runner is unavailable, and deletes it afterwards. It is not how a repository chooses its runner permanently. +For repositories that receive the release-intent gate, the bootstrap also creates any missing `major`, `minor`, `patch` and `no-release` labels, even when workflow files are already up to date. Existing labels are matched case-insensitively and are never edited, renamed, recolored or deleted. Repositories that do not receive the release-intent gate get no labels. + A repository that keeps its own copy of one bootstrapped file, and takes every other one, is listed for that file in the script's `SKIP_FILE_REPOS`: Arc.Kotlin keeps its SHA-pinned `verify-semver-label.yml`. Repositories in `REPOS_TO_IGNORE` never receive the callers automatically and need them added through their own reviewed change. That covers the repositories that hold their own governance (AI, Templates, release-action, Stage, Dockerfiles, and Chronicle.Dapr and Chronicle.Wolverine once they release), the private repositories (Studio, Direct, Ensemble, Infrastructure, Experiments, Strategy, Identity, KeldaKraft, studio-cluster-verification) and the public repositories that customize `update-packages.yml` on purpose (Ante, Chronicle.Elixir, Components, Orleans). Private repositories have no GitHub-hosted Actions budget and run their wrappers on `cratis-arc`, so the bootstrap must not overwrite those wrappers with `ubuntu-latest` ones, and a repository that removed a wrapper on purpose must not get it back. Such a repository copies the canonical caller and gives the gate its own runner fallback, in the same shape as its other wrappers: `runs-on: ${{ vars.RUNNER_GATE || 'cratis-arc' }}`, or, in Ensemble, `runs-on: ${{ vars.ENSEMBLE_RUNNER || 'cratis-arc' }}`. It does not set `RUNNER_GATE` permanently. A new private repository, or a new repository with a deliberate customization, is added to `REPOS_TO_IGNORE` with the reason in the comment beside it (the bootstrap's spec asserts that the private repositories and the customized ones stay listed). AI.Distribution is no longer in use and is ignored by both the bootstrap and template propagation. Arc.TypeScript, Chronicle.Python and the two Eventmodelers build kits were never bootstrapped, so a run would also install every standard wrapper there for the first time; they are held back until that is decided.