imbi-plugin-github 2.26.2__tar.gz → 2.28.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/deployment.py +85 -16
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_deployment.py +102 -5
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/.gitignore +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/README.md +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/moon.yml +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_pull_requests.py +0 -0
- {imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/tests/test_repos.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.28.0
|
|
4
4
|
Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
|
|
5
5
|
Author-email: "Gavin M. Roy" <gavinr@aweber.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
13
13
|
Requires-Python: >=3.14
|
|
14
14
|
Requires-Dist: httpx>=0.27
|
|
15
|
-
Requires-Dist: imbi-common[databases]==2.
|
|
15
|
+
Requires-Dist: imbi-common[databases]==2.28.0
|
|
16
16
|
Requires-Dist: pydantic>=2
|
|
17
17
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-plugin-github"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.28.0"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
]
|
|
19
19
|
dependencies = [
|
|
20
20
|
"httpx>=0.27",
|
|
21
|
-
"imbi-common[databases]==2.
|
|
21
|
+
"imbi-common[databases]==2.28.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -1486,23 +1486,68 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1486
1486
|
|
|
1487
1487
|
``run_id`` is the GitHub deployment id returned by
|
|
1488
1488
|
:meth:`trigger_deployment`. GitHub returns status updates
|
|
1489
|
-
newest-first; the
|
|
1490
|
-
|
|
1491
|
-
as ``'queued'``.
|
|
1492
|
-
|
|
1493
|
-
``
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1489
|
+
newest-first; the newest entry that describes this deployment's
|
|
1490
|
+
own lifecycle wins. An empty list means the deploy workflow
|
|
1491
|
+
hasn't posted anything yet, which Imbi surfaces as ``'queued'``.
|
|
1492
|
+
|
|
1493
|
+
``inactive`` entries are skipped rather than read as the answer.
|
|
1494
|
+
Every other state is something this deployment's own run
|
|
1495
|
+
reported about itself; ``inactive`` is written by GitHub *on
|
|
1496
|
+
behalf of a later deployment* when that one supersedes this one.
|
|
1497
|
+
Treating it as the outcome is wrong twice over: it relabels a
|
|
1498
|
+
rollout that succeeded (the success is still in the list, one
|
|
1499
|
+
entry further down), and its ``updated_at`` is the moment the
|
|
1500
|
+
successor went live, so a caller stamping the close-out with
|
|
1501
|
+
``completed_at`` would date this deployment *after* the one that
|
|
1502
|
+
replaced it. That is what taught a production environment it
|
|
1503
|
+
was running a release eleven days stale.
|
|
1504
|
+
|
|
1505
|
+
Note that ``inactive`` reaches Imbi only by polling -- here and
|
|
1506
|
+
in :meth:`_latest_status`. GitHub creates the status but emits no
|
|
1507
|
+
``deployment_status`` webhook for it, which its own docs state
|
|
1508
|
+
outright: "A webhook event is not fired for deployment statuses
|
|
1509
|
+
with an inactive state." Confirmed 2026-08-21 against a repo
|
|
1510
|
+
hook subscribed to ``*``, which saw the successor's ``success``
|
|
1511
|
+
and nothing for the auto-inactive written in the same second.
|
|
1512
|
+
So a poll is the only place the state can be handled correctly,
|
|
1513
|
+
and there is no upstream fix to wait for.
|
|
1514
|
+
|
|
1515
|
+
Suppressing the state at source is not the alternative it looks
|
|
1516
|
+
like: ``auto_inactive`` is documented as affecting only
|
|
1517
|
+
"non-transient, non-production" deployments, yet the deployments
|
|
1518
|
+
observed here carry ``production_environment=true`` and were
|
|
1519
|
+
auto-inactivated anyway. The documented carve-out does not
|
|
1520
|
+
describe them, so setting ``auto_inactive=false`` would need an
|
|
1521
|
+
experiment rather than a reading.
|
|
1522
|
+
|
|
1523
|
+
A deployment superseded while still in flight has no terminal
|
|
1524
|
+
entry left once ``inactive`` is skipped, so it reads as whatever
|
|
1525
|
+
it last genuinely reported and the sweeper expires it on age --
|
|
1526
|
+
honest, because it never did finish.
|
|
1527
|
+
|
|
1528
|
+
``log_url`` (and the legacy ``target_url``) on the selected
|
|
1529
|
+
status is what the deploy workflow set to point at its own logs
|
|
1530
|
+
(e.g. the Actions run URL). We carry that as ``run_url`` so the
|
|
1531
|
+
UI can deep-link without having to walk back to the workflow run
|
|
1497
1532
|
through a check-suite join.
|
|
1498
1533
|
"""
|
|
1499
1534
|
async with self._client(ctx, credentials) as client:
|
|
1500
1535
|
resp = await client.get(f'/deployments/{run_id}/statuses')
|
|
1501
1536
|
resp.raise_for_status()
|
|
1502
1537
|
statuses = typing.cast(list[dict[str, typing.Any]], resp.json())
|
|
1503
|
-
|
|
1538
|
+
latest = next(
|
|
1539
|
+
(
|
|
1540
|
+
entry
|
|
1541
|
+
for entry in statuses
|
|
1542
|
+
if str(entry.get('state') or '').lower() != 'inactive'
|
|
1543
|
+
),
|
|
1544
|
+
None,
|
|
1545
|
+
)
|
|
1546
|
+
if latest is None:
|
|
1547
|
+
# Either nothing posted yet, or every entry is an
|
|
1548
|
+
# auto-inactive -- both mean this deployment has told us
|
|
1549
|
+
# nothing about itself.
|
|
1504
1550
|
return DeploymentRun(run_id=str(run_id), status='queued')
|
|
1505
|
-
latest = statuses[0]
|
|
1506
1551
|
state = str(latest.get('state') or '').lower()
|
|
1507
1552
|
status: typing.Literal[
|
|
1508
1553
|
'queued',
|
|
@@ -1520,10 +1565,6 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1520
1565
|
status = 'success'
|
|
1521
1566
|
elif state in {'failure', 'error'}:
|
|
1522
1567
|
status = 'failure'
|
|
1523
|
-
elif state == 'inactive':
|
|
1524
|
-
# Deployment was superseded by a newer one for the same
|
|
1525
|
-
# env — Imbi treats that as cancelled rather than failed.
|
|
1526
|
-
status = 'cancelled'
|
|
1527
1568
|
else:
|
|
1528
1569
|
status = 'unknown'
|
|
1529
1570
|
log_url = latest.get('log_url') or latest.get('target_url')
|
|
@@ -1906,11 +1947,32 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1906
1947
|
not started, and ``pending`` is the host's vocabulary for
|
|
1907
1948
|
both. Network / parse errors degrade the same way so resync
|
|
1908
1949
|
is never blocked by a single noisy row.
|
|
1950
|
+
|
|
1951
|
+
An ``inactive`` entry is looked *past* when something sits
|
|
1952
|
+
beneath it. GitHub writes one on a deployment when a later one
|
|
1953
|
+
supersedes it, so it reports the successor's arrival rather than
|
|
1954
|
+
this deployment's outcome -- and every deployment except an
|
|
1955
|
+
environment's newest carries one. Reading it verbatim therefore
|
|
1956
|
+
relabelled whole deployment histories as ``rolled_back``, each
|
|
1957
|
+
node's ``history`` showing the ``success`` it overwrote; ~14k
|
|
1958
|
+
nodes in the production graph were in that state before this
|
|
1959
|
+
skip existed.
|
|
1960
|
+
|
|
1961
|
+
An ``inactive`` with nothing beneath it still reports
|
|
1962
|
+
``rolled_back``: that deployment was superseded without ever
|
|
1963
|
+
reporting on itself, and ``rolled_back`` is the accurate
|
|
1964
|
+
terminal for it. The distinction is the whole point -- skip the
|
|
1965
|
+
retirement notice when it is hiding a real outcome, keep it when
|
|
1966
|
+
it is the only thing we know.
|
|
1967
|
+
|
|
1968
|
+
This is why the page size is not 1. A single-status window
|
|
1969
|
+
cannot see past an ``inactive`` to the ``success`` underneath,
|
|
1970
|
+
which is what made the misread unavoidable.
|
|
1909
1971
|
"""
|
|
1910
1972
|
try:
|
|
1911
1973
|
resp = await client.get(
|
|
1912
1974
|
f'/deployments/{deployment_id}/statuses',
|
|
1913
|
-
params={'per_page': '
|
|
1975
|
+
params={'per_page': '10'},
|
|
1914
1976
|
)
|
|
1915
1977
|
except httpx.HTTPError:
|
|
1916
1978
|
return 'pending', None
|
|
@@ -1922,7 +1984,14 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1922
1984
|
return 'pending', None
|
|
1923
1985
|
if not statuses:
|
|
1924
1986
|
return 'pending', None
|
|
1925
|
-
latest =
|
|
1987
|
+
latest = next(
|
|
1988
|
+
(
|
|
1989
|
+
entry
|
|
1990
|
+
for entry in statuses
|
|
1991
|
+
if str(entry.get('state') or '').lower() != 'inactive'
|
|
1992
|
+
),
|
|
1993
|
+
statuses[0],
|
|
1994
|
+
)
|
|
1926
1995
|
state = str(latest.get('state') or '').lower()
|
|
1927
1996
|
log_url = latest.get('log_url') or latest.get('target_url')
|
|
1928
1997
|
return _to_event_status(state), str(log_url) if log_url else None
|
|
@@ -905,20 +905,60 @@ class GetDeploymentStatusTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
905
905
|
self.assertEqual(run.status, 'failure')
|
|
906
906
|
|
|
907
907
|
@respx.mock
|
|
908
|
-
async def
|
|
909
|
-
|
|
910
|
-
|
|
908
|
+
async def test_status_skips_inactive_to_the_real_outcome(self) -> None:
|
|
909
|
+
"""``inactive`` hides the success it was written on top of.
|
|
910
|
+
|
|
911
|
+
GitHub stamps ``inactive`` on a deployment when a *later* one
|
|
912
|
+
supersedes it, so it is newest-first but describes the wrong
|
|
913
|
+
rollout. Reading it as the answer relabelled a succeeded
|
|
914
|
+
deployment as cancelled -> failed, and -- worse -- carried the
|
|
915
|
+
successor's ``updated_at`` as this deployment's completion, so
|
|
916
|
+
the close-out sorted after the release that replaced it.
|
|
917
|
+
"""
|
|
911
918
|
respx.get(
|
|
912
919
|
'https://api.github.com/repos/octo/demo/deployments/42/statuses'
|
|
913
920
|
).mock(
|
|
914
921
|
return_value=httpx.Response(
|
|
915
922
|
200,
|
|
916
|
-
json=[
|
|
923
|
+
json=[
|
|
924
|
+
{
|
|
925
|
+
'state': 'inactive',
|
|
926
|
+
'created_at': '2026-08-14T19:51:26Z',
|
|
927
|
+
'updated_at': '2026-08-14T19:51:26Z',
|
|
928
|
+
'log_url': 'https://gh/runs/stale',
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
'state': 'success',
|
|
932
|
+
'created_at': '2026-08-03T19:03:32Z',
|
|
933
|
+
'updated_at': '2026-08-03T19:03:32Z',
|
|
934
|
+
'log_url': 'https://gh/runs/real',
|
|
935
|
+
},
|
|
936
|
+
{'state': 'in_progress'},
|
|
937
|
+
],
|
|
917
938
|
)
|
|
918
939
|
)
|
|
919
940
|
plugin = GitHubDeployment()
|
|
920
941
|
run = await plugin.get_deployment_status(_ctx(), _CREDS, '42')
|
|
921
|
-
self.assertEqual(run.status, '
|
|
942
|
+
self.assertEqual(run.status, 'success')
|
|
943
|
+
self.assertEqual(run.run_url, 'https://gh/runs/real')
|
|
944
|
+
assert run.completed_at is not None
|
|
945
|
+
self.assertEqual(
|
|
946
|
+
datetime.datetime(2026, 8, 3, 19, 3, 32, tzinfo=datetime.UTC),
|
|
947
|
+
run.completed_at,
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
@respx.mock
|
|
951
|
+
async def test_status_only_inactive_reads_as_queued(self) -> None:
|
|
952
|
+
# Superseded without ever reporting on itself: there is no
|
|
953
|
+
# outcome to read, so it stays unresolved and the sweeper
|
|
954
|
+
# expires it on age rather than inventing a terminal status.
|
|
955
|
+
respx.get(
|
|
956
|
+
'https://api.github.com/repos/octo/demo/deployments/42/statuses'
|
|
957
|
+
).mock(return_value=httpx.Response(200, json=[{'state': 'inactive'}]))
|
|
958
|
+
plugin = GitHubDeployment()
|
|
959
|
+
run = await plugin.get_deployment_status(_ctx(), _CREDS, '42')
|
|
960
|
+
self.assertEqual(run.status, 'queued')
|
|
961
|
+
self.assertIsNone(run.completed_at)
|
|
922
962
|
|
|
923
963
|
|
|
924
964
|
class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
@@ -975,6 +1015,63 @@ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
|
|
|
975
1015
|
self.assertEqual(len(events), 1)
|
|
976
1016
|
self.assertEqual(events[0].sha, 'appsha')
|
|
977
1017
|
|
|
1018
|
+
@respx.mock
|
|
1019
|
+
async def test_resync_skips_inactive_to_the_real_outcome(self) -> None:
|
|
1020
|
+
"""Resync must not relabel deployment history as rolled_back.
|
|
1021
|
+
|
|
1022
|
+
Every deployment except an environment's newest carries an
|
|
1023
|
+
``inactive`` status, written when its successor went live.
|
|
1024
|
+
Reading the newest entry verbatim turned each of them into
|
|
1025
|
+
``rolled_back``, overwriting the ``success`` directly beneath
|
|
1026
|
+
it -- ~14k nodes in the production graph, whose ``history``
|
|
1027
|
+
records the success-then-rolled_back flip.
|
|
1028
|
+
"""
|
|
1029
|
+
respx.get(
|
|
1030
|
+
'https://api.github.com/repos/octo/demo/deployments',
|
|
1031
|
+
params={'environment': 'production', 'per_page': '1'},
|
|
1032
|
+
).mock(
|
|
1033
|
+
return_value=httpx.Response(
|
|
1034
|
+
200,
|
|
1035
|
+
json=[
|
|
1036
|
+
{
|
|
1037
|
+
'id': 55,
|
|
1038
|
+
'sha': 'deadbeef',
|
|
1039
|
+
'ref': 'main',
|
|
1040
|
+
'created_at': '2026-05-13T14:00:00Z',
|
|
1041
|
+
}
|
|
1042
|
+
],
|
|
1043
|
+
)
|
|
1044
|
+
)
|
|
1045
|
+
respx.get(
|
|
1046
|
+
'https://api.github.com/repos/octo/demo/deployments/55/statuses'
|
|
1047
|
+
).mock(
|
|
1048
|
+
return_value=httpx.Response(
|
|
1049
|
+
200,
|
|
1050
|
+
json=[
|
|
1051
|
+
{
|
|
1052
|
+
'state': 'inactive',
|
|
1053
|
+
'created_at': '2026-05-20T09:00:00Z',
|
|
1054
|
+
'log_url': 'https://gh/runs/successor',
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
'state': 'success',
|
|
1058
|
+
'created_at': '2026-05-13T14:01:00Z',
|
|
1059
|
+
'log_url': 'https://gh/runs/real',
|
|
1060
|
+
},
|
|
1061
|
+
],
|
|
1062
|
+
)
|
|
1063
|
+
)
|
|
1064
|
+
respx.get(
|
|
1065
|
+
'https://api.github.com/repos/octo/demo/releases/tags/main'
|
|
1066
|
+
).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
|
|
1067
|
+
plugin = GitHubDeployment()
|
|
1068
|
+
events = await plugin.list_recent_deployments(
|
|
1069
|
+
_ctx(), _CREDS, ['production']
|
|
1070
|
+
)
|
|
1071
|
+
self.assertEqual(len(events), 1)
|
|
1072
|
+
self.assertEqual(events[0].status, 'success')
|
|
1073
|
+
self.assertEqual(events[0].run_url, 'https://gh/runs/real')
|
|
1074
|
+
|
|
978
1075
|
@respx.mock
|
|
979
1076
|
async def test_one_env_one_deployment_success(self) -> None:
|
|
980
1077
|
respx.get(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/_app_auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.26.2 → imbi_plugin_github-2.28.0}/src/imbi/plugins/github/pull_requests.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|