Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions .github/workflows/update-upstream.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions scripts/publish_generated_branch.py
Original file line number Diff line number Diff line change
@@ -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()
127 changes: 127 additions & 0 deletions scripts/test_publish_generated_branch.py
Original file line number Diff line number Diff line change
@@ -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()
Loading