diff --git a/.github/workflows/update-upstream.yml b/.github/workflows/update-upstream.yml index 95d4824..18363ef 100644 --- a/.github/workflows/update-upstream.yml +++ b/.github/workflows/update-upstream.yml @@ -33,6 +33,7 @@ jobs: set -euo pipefail bash -n scripts/update_upstream.sh python3 scripts/test_update_whitespace.py + python3 scripts/test_publish_generated_branch.py variant=$(tr -d '[:space:]' < UPSTREAM_VARIANT) scripts/update_upstream.sh "$variant" --check-only git diff --exit-code @@ -193,30 +194,37 @@ jobs: echo 'No generated changes to propose.' exit 0 fi - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git switch -c "$UPDATE_BRANCH" - git commit -m "Update $VARIANT ImGui and ImPlot bindings" remote_ref="refs/heads/$UPDATE_BRANCH" remote_sha=$(git ls-remote origin "$remote_ref" | awk '{print $1}') if [[ -n "$remote_sha" ]]; then existing=$(gh pr list --base "$BASE_BRANCH" --head "$UPDATE_BRANCH" \ --state open --json number --jq '.[0].number // empty') - [[ -n "$existing" ]] || { - echo "Refusing to replace $remote_ref without an open update PR." >&2 - exit 1 - } - git push --force-with-lease="$remote_ref:$remote_sha" origin "HEAD:$remote_ref" - echo "Updated PR #$existing" - else - git push origin "HEAD:$remote_ref" + if [[ -z "$existing" ]]; then + git fetch --no-tags origin "$remote_ref" + [[ $(git rev-parse FETCH_HEAD) == "$remote_sha" ]] || exit 1 + [[ $(git write-tree) == $(git rev-parse 'FETCH_HEAD^{tree}') ]] || { + echo "Refusing to replace $remote_ref without an open update PR." >&2 + exit 1 + } + fi + fi + before=$(git rev-parse HEAD) + python3 scripts/publish_generated_branch.py "$UPDATE_BRANCH" "Update $VARIANT ImGui and ImPlot bindings" + pushed=false + if [[ $(git rev-parse HEAD) != "$before" ]]; then pushed=true; fi + # Even after a no-op push, recover a missing PR without editing an existing one. + existing=$(gh pr list --base "$BASE_BRANCH" --head "$UPDATE_BRANCH" \ + --state open --json number --jq '.[0].number // empty') + created=false + if [[ -z "$existing" ]]; then gh pr create --draft --base "$BASE_BRANCH" --head "$UPDATE_BRANCH" \ --title "Update $VARIANT ImGui and ImPlot bindings" \ --body "Automated upstream refresh for the $VARIANT variant. cimgui: $(git -C cimgui rev-parse HEAD); cimplot: $(git -C cimplot rev-parse HEAD). Generated V bindings and native sources must pass CI and receive human review before merge." + created=true fi # A repository-token PR needs explicit CI dispatch. A personal token # already triggers PR checks, so do not duplicate them. - if [[ "$DISPATCH_CI" == true ]]; then + if [[ "$DISPATCH_CI" == true && ( "$pushed" == true || "$created" == true ) ]]; then for workflow in demo-release.yml mobile-native.yml application-accessibility.yml portable-tooling.yml; do gh workflow run "$workflow" --ref "$UPDATE_BRANCH" done diff --git a/README.md b/README.md index 00b8fc0..7b9cc02 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,11 @@ This repository supports both official Dear ImGui lines: multi-viewport support while retaining the normal Dear ImGui API. - The `standard` branch tracks Dear ImGui's smaller standard line. -Both currently track Dear ImGui `1.92.9b`. The `b` is an upstream patch-level -suffix shared by the release; it does not identify the docking variant. Check +The standard line tracks Dear ImGui `1.93.0`; the docking line remains on +`1.92.9b` until its upstream generated docking API is ready. See +[Migrating to 1.93](https://github.com/antono2/imgui/blob/standard/docs/migrating-1.93.md) +before upgrading a standard consumer. +The `b` is an upstream patch-level suffix, not a docking marker. Check `UPSTREAM_VARIANT` in a checkout to see which line its generated bindings and native sources use. Do not mix a generated binding from one line with a native library built from the other. diff --git a/scripts/publish_generated_branch.py b/scripts/publish_generated_branch.py new file mode 100644 index 0000000..860afa7 --- /dev/null +++ b/scripts/publish_generated_branch.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Push staged generated files only when their tree differs from the existing proposal. + +The caller stages the distribution files over a clean target default branch. +No-op runs leave both the local commit and remote proposal untouched; real +updates use the observed remote revision as an explicit force-with-lease. +""" + +import argparse +import os +from pathlib import Path +import subprocess + + +def git(repo, *args): + return subprocess.check_output(['git', *args], cwd=repo, text=True).strip() + + +def publish(repo, branch, message): + ref = 'refs/heads/' + branch + git(repo, 'check-ref-format', ref) + # ls-remote without --exit-code succeeds with empty output for a missing ref, + # but still propagates authentication/network failures. + remote = git(repo, 'ls-remote', '--heads', 'origin', ref) + remote_sha = remote.split()[0] if remote else None + desired_tree = git(repo, 'write-tree') + if remote_sha: + git(repo, 'fetch', '--no-tags', 'origin', ref) + if git(repo, 'rev-parse', 'FETCH_HEAD') != remote_sha: + raise RuntimeError('Proposal moved during comparison; retry later') + if desired_tree == git(repo, 'rev-parse', remote_sha + '^{tree}'): + print('Generated files already match the existing proposal; no commit or push.') + return False + if desired_tree == git(repo, 'rev-parse', 'HEAD^{tree}'): + print('Generated files already match the default branch; no commit or push.') + return False + git(repo, '-c', 'user.name=github-actions[bot]', '-c', + 'user.email=41898282+github-actions[bot]@users.noreply.github.com', + 'commit', '-m', message) + # An empty expected revision also protects creation against a concurrent push. + git(repo, 'push', '--force-with-lease=' + ref + ':' + (remote_sha or ''), + 'origin', 'HEAD:' + ref) + return True + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('branch') + parser.add_argument('message') + args = parser.parse_args() + pushed = publish(Path.cwd(), args.branch, args.message) + if output := os.environ.get('GITHUB_OUTPUT'): + with open(output, 'a', encoding='utf-8') as stream: + stream.write('pushed=' + str(pushed).lower() + '\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/test_publish_generated_branch.py b/scripts/test_publish_generated_branch.py new file mode 100644 index 0000000..5b4616b --- /dev/null +++ b/scripts/test_publish_generated_branch.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Exercise proposal idempotency, real updates and lease races against local Git remotes.""" + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import textwrap +import unittest +from unittest.mock import patch + +import publish_generated_branch as publisher + + +SCRIPT = Path(publisher.__file__).resolve() +BRANCH = 'automation/generated-update' + + +class PublishTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.remote = self.root / 'remote.git' + self.work = self.root / 'work' + self.work.mkdir() + self.command('git', 'init', '--bare', str(self.remote)) + self.git('init', '--initial-branch=master') + self.git('config', 'user.name', 'Fixture') + self.git('config', 'user.email', 'fixture@example.invalid') + (self.work / 'generated.txt').write_text('old bindings\n') + self.git('add', '.') + self.git('commit', '-m', 'base') + self.git('remote', 'add', 'origin', str(self.remote)) + self.git('push', 'origin', 'master') + self.base = self.git('rev-parse', 'HEAD') + + def command(self, *args, **kwargs): + return subprocess.check_output(args, cwd=self.work, text=True, + stderr=subprocess.PIPE, **kwargs).strip() + + def git(self, *args): + return self.command('git', *args) + + def revision(self): + return self.git('ls-remote', 'origin', 'refs/heads/' + BRANCH).split()[0] + + def stage(self, text='new bindings\n'): + (self.work / 'generated.txt').write_text(text) + self.git('add', 'generated.txt') + + def run_publisher(self): + output = self.root / 'output' + output.unlink(missing_ok=True) + self.command(sys.executable, str(SCRIPT), BRANCH, 'Generated bindings', + env={**os.environ, 'GITHUB_OUTPUT': str(output)}) + return output.read_text() + + def reset_to_base(self): + # Destructive only inside this test's own temporary fixture checkout. + self.git('reset', '--hard', self.base) + + def test_first_proposal_is_pushed(self): + self.stage() + self.assertEqual(self.run_publisher(), 'pushed=true\n') + self.assertNotEqual(self.revision(), self.base) + + def test_repeat_preserves_remote_head_and_creates_no_local_commit(self): + self.stage() + self.run_publisher() + previous = self.revision() + self.reset_to_base() + self.stage() + self.assertEqual(self.run_publisher(), 'pushed=false\n') + self.assertEqual(self.revision(), previous) + self.assertEqual(self.git('rev-parse', 'HEAD'), self.base) + + def test_changed_output_updates_same_version_branch(self): + self.stage() + self.run_publisher() + previous = self.revision() + self.reset_to_base() + self.stage('corrected generator output\n') + self.assertEqual(self.run_publisher(), 'pushed=true\n') + self.assertNotEqual(self.revision(), previous) + + def test_real_base_file_change_is_retained(self): + self.stage() + self.run_publisher() + previous = self.revision() + self.reset_to_base() + (self.work / 'loader.v').write_text('updated loader\n') + self.git('add', '.') + self.git('commit', '-m', 'base update') + self.stage() + self.assertEqual(self.run_publisher(), 'pushed=true\n') + self.assertNotEqual(self.revision(), previous) + self.assertEqual(self.git('show', 'HEAD:loader.v'), 'updated loader') + + def test_default_branch_noop_does_not_create_a_proposal(self): + self.assertEqual(self.run_publisher(), 'pushed=false\n') + self.assertEqual(self.git('ls-remote', 'origin', 'refs/heads/' + BRANCH), '') + + def test_concurrent_remote_update_is_not_overwritten(self): + self.stage() + self.run_publisher() + previous = self.revision() + self.reset_to_base() + self.stage('another update\n') + original_git = publisher.git + + def race(repo, *args): + if args[0] == 'push': + self.command('git', '--git-dir', str(self.remote), 'update-ref', + 'refs/heads/' + BRANCH, self.base, previous) + return original_git(repo, *args) + + with patch.object(publisher, 'git', side_effect=race): + with self.assertRaises(subprocess.CalledProcessError): + publisher.publish(self.work, BRANCH, 'Generated bindings') + self.assertEqual(self.revision(), self.base) + + + +if __name__ == '__main__': + unittest.main()