imbi-plugin-github 2.27.0__tar.gz → 2.29.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.27.0 → imbi_plugin_github-2.29.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/deployment.py +439 -35
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/plugin.py +18 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_deployment.py +674 -5
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/.gitignore +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/README.md +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/moon.yml +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/commits.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_commits.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_pull_requests.py +0 -0
- {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.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.29.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.29.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.29.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.29.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
{imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -53,6 +53,7 @@ from imbi.common.plugins.base import (
|
|
|
53
53
|
DeploymentCapability,
|
|
54
54
|
DeploymentEventStatus,
|
|
55
55
|
DeploymentRun,
|
|
56
|
+
EnvironmentDeploymentState,
|
|
56
57
|
LinkWriteback,
|
|
57
58
|
NotesListing,
|
|
58
59
|
PluginContext,
|
|
@@ -152,6 +153,21 @@ _NOTE_BLOB_CONCURRENCY = 10
|
|
|
152
153
|
_FULL_SHA_PATTERN = re.compile(r'^[0-9a-f]{40}$')
|
|
153
154
|
|
|
154
155
|
|
|
156
|
+
class _StatusRead(typing.NamedTuple):
|
|
157
|
+
"""One deployment's status history, as far as it could be read."""
|
|
158
|
+
|
|
159
|
+
status: DeploymentEventStatus
|
|
160
|
+
log_url: str | None
|
|
161
|
+
#: The newest entry is GitHub's ``inactive`` notice, so this
|
|
162
|
+
#: deployment has been retired whatever ``status`` says it did.
|
|
163
|
+
superseded: bool
|
|
164
|
+
#: Nothing was read -- ``status`` is the ``pending`` fallback rather
|
|
165
|
+
#: than an observation. Resync wants that fallback (a noisy row must
|
|
166
|
+
#: not fail a whole project); anything deciding what an environment
|
|
167
|
+
#: serves has to know the difference.
|
|
168
|
+
unreadable: bool
|
|
169
|
+
|
|
170
|
+
|
|
155
171
|
def _note_sha(path: object) -> str | None:
|
|
156
172
|
"""Annotated commit SHA for a notes-tree path, or ``None``.
|
|
157
173
|
|
|
@@ -435,6 +451,43 @@ def _mainline_branches(
|
|
|
435
451
|
return configured or _DEFAULT_MAINLINE_BRANCHES
|
|
436
452
|
|
|
437
453
|
|
|
454
|
+
# How many deployments per environment ``get_environment_state`` walks
|
|
455
|
+
# before it stops looking for the active one. GitHub returns
|
|
456
|
+
# deployments newest-first, and the active deployment is normally the
|
|
457
|
+
# first or second row; a deeper walk only pays off on an environment
|
|
458
|
+
# whose recent history is a run of failures. Each row costs one status
|
|
459
|
+
# request, so the cap bounds the request count per environment rather
|
|
460
|
+
# than the wall time of one call.
|
|
461
|
+
_DEFAULT_ACTIVE_SCAN_LIMIT = 10
|
|
462
|
+
# One page holds the whole scan -- GitHub caps ``per_page`` at 100.
|
|
463
|
+
_MAX_ACTIVE_SCAN_LIMIT = 100
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _active_scan_limit(
|
|
467
|
+
integration_options: dict[str, typing.Any],
|
|
468
|
+
) -> int:
|
|
469
|
+
"""Resolve the ``active_scan_limit`` integration option.
|
|
470
|
+
|
|
471
|
+
Declared integration-level beside ``mainline_branches`` because how
|
|
472
|
+
deep the scan has to go is a property of the org's deploy habits, not
|
|
473
|
+
of one capability. Operator-entered values arrive as strings from the
|
|
474
|
+
admin form as often as integers, so both are accepted; anything absent,
|
|
475
|
+
unparseable, or below 1 resolves to
|
|
476
|
+
:data:`_DEFAULT_ACTIVE_SCAN_LIMIT`, and the value is clamped to what a
|
|
477
|
+
single GitHub page can carry.
|
|
478
|
+
"""
|
|
479
|
+
raw = integration_options.get('active_scan_limit')
|
|
480
|
+
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
|
|
481
|
+
return _DEFAULT_ACTIVE_SCAN_LIMIT
|
|
482
|
+
try:
|
|
483
|
+
limit = int(raw)
|
|
484
|
+
except ValueError:
|
|
485
|
+
return _DEFAULT_ACTIVE_SCAN_LIMIT
|
|
486
|
+
if limit < 1:
|
|
487
|
+
return _DEFAULT_ACTIVE_SCAN_LIMIT
|
|
488
|
+
return min(limit, _MAX_ACTIVE_SCAN_LIMIT)
|
|
489
|
+
|
|
490
|
+
|
|
438
491
|
def _commit_from_payload(payload: dict[str, typing.Any]) -> Commit:
|
|
439
492
|
"""Convert a GitHub commit list/object payload into a :class:`Commit`."""
|
|
440
493
|
sha = str(payload.get('sha', ''))
|
|
@@ -912,23 +965,26 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
912
965
|
ctx: PluginContext,
|
|
913
966
|
credentials: dict[str, str],
|
|
914
967
|
namespace: str,
|
|
968
|
+
skip_shas: collections.abc.Collection[str] = (),
|
|
915
969
|
) -> NotesListing:
|
|
916
970
|
"""Every note on ``refs/notes/<namespace>`` at its current tip.
|
|
917
971
|
|
|
918
972
|
Two Git Data calls to reach the tree, then one blob read per
|
|
919
|
-
note. A missing ref answers
|
|
973
|
+
note the caller did not ask us to skip. A missing ref answers
|
|
974
|
+
an empty, complete listing.
|
|
920
975
|
|
|
921
976
|
``complete`` compares what the tree holds against what came
|
|
922
977
|
back: :meth:`_all_notes` drops a note whose blob it cannot read
|
|
923
978
|
(logging why), and a truncated tree listing hides notes before
|
|
924
979
|
that. Either way the caller must not treat the result as the
|
|
925
|
-
whole ref.
|
|
980
|
+
whole ref. A note skipped on request does not make the listing
|
|
981
|
+
incomplete -- the caller already has that answer.
|
|
926
982
|
"""
|
|
927
983
|
async with self._client(ctx, credentials) as client:
|
|
928
984
|
tip = await self._notes_ref_tip(client, namespace)
|
|
929
985
|
if tip is None:
|
|
930
986
|
return NotesListing({}, True)
|
|
931
|
-
return await self._all_notes(client, tip)
|
|
987
|
+
return await self._all_notes(client, tip, skip_shas)
|
|
932
988
|
|
|
933
989
|
async def diff_commit_notes(
|
|
934
990
|
self,
|
|
@@ -1080,17 +1136,22 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1080
1136
|
return out, not truncated
|
|
1081
1137
|
|
|
1082
1138
|
async def _all_notes(
|
|
1083
|
-
self,
|
|
1139
|
+
self,
|
|
1140
|
+
client: httpx.AsyncClient,
|
|
1141
|
+
commit_sha: str,
|
|
1142
|
+
skip_shas: collections.abc.Collection[str] = (),
|
|
1084
1143
|
) -> NotesListing:
|
|
1085
1144
|
"""Every note at one notes-ref commit, bodies included.
|
|
1086
1145
|
|
|
1087
1146
|
Blob reads run a few at a time (one request per note) and an
|
|
1088
1147
|
unreadable note is skipped rather than failing the batch or
|
|
1089
|
-
recording a false "removed".
|
|
1148
|
+
recording a false "removed". A note whose annotated commit is
|
|
1149
|
+
in ``skip_shas`` costs no request at all.
|
|
1090
1150
|
|
|
1091
1151
|
``complete`` combines the two ways this can fall short: a
|
|
1092
1152
|
truncated tree listing, and a blob that would not read. Either
|
|
1093
|
-
means the map is not the whole ref.
|
|
1153
|
+
means the map is not the whole ref. Notes skipped on request do
|
|
1154
|
+
not count against it.
|
|
1094
1155
|
"""
|
|
1095
1156
|
notes, tree_complete = await self._tree_notes(client, commit_sha)
|
|
1096
1157
|
gate = asyncio.Semaphore(_NOTE_BLOB_CONCURRENCY)
|
|
@@ -1102,7 +1163,12 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1102
1163
|
except httpx.HTTPError as exc:
|
|
1103
1164
|
return exc
|
|
1104
1165
|
|
|
1105
|
-
|
|
1166
|
+
skip = {sha.lower() for sha in skip_shas}
|
|
1167
|
+
items = [
|
|
1168
|
+
(annotated, blob_sha)
|
|
1169
|
+
for annotated, blob_sha in notes.items()
|
|
1170
|
+
if annotated.lower() not in skip
|
|
1171
|
+
]
|
|
1106
1172
|
bodies = await asyncio.gather(
|
|
1107
1173
|
*(_read(blob_sha) for _, blob_sha in items)
|
|
1108
1174
|
)
|
|
@@ -1120,7 +1186,7 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1120
1186
|
# a ``None`` here would read as "note removed".
|
|
1121
1187
|
continue
|
|
1122
1188
|
out[annotated] = body
|
|
1123
|
-
return NotesListing(out, tree_complete and len(out) == len(
|
|
1189
|
+
return NotesListing(out, tree_complete and len(out) == len(items))
|
|
1124
1190
|
|
|
1125
1191
|
@staticmethod
|
|
1126
1192
|
async def _blob_text(
|
|
@@ -1486,23 +1552,68 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1486
1552
|
|
|
1487
1553
|
``run_id`` is the GitHub deployment id returned by
|
|
1488
1554
|
:meth:`trigger_deployment`. GitHub returns status updates
|
|
1489
|
-
newest-first; the
|
|
1490
|
-
|
|
1491
|
-
as ``'queued'``.
|
|
1492
|
-
|
|
1493
|
-
``
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1555
|
+
newest-first; the newest entry that describes this deployment's
|
|
1556
|
+
own lifecycle wins. An empty list means the deploy workflow
|
|
1557
|
+
hasn't posted anything yet, which Imbi surfaces as ``'queued'``.
|
|
1558
|
+
|
|
1559
|
+
``inactive`` entries are skipped rather than read as the answer.
|
|
1560
|
+
Every other state is something this deployment's own run
|
|
1561
|
+
reported about itself; ``inactive`` is written by GitHub *on
|
|
1562
|
+
behalf of a later deployment* when that one supersedes this one.
|
|
1563
|
+
Treating it as the outcome is wrong twice over: it relabels a
|
|
1564
|
+
rollout that succeeded (the success is still in the list, one
|
|
1565
|
+
entry further down), and its ``updated_at`` is the moment the
|
|
1566
|
+
successor went live, so a caller stamping the close-out with
|
|
1567
|
+
``completed_at`` would date this deployment *after* the one that
|
|
1568
|
+
replaced it. That is what taught a production environment it
|
|
1569
|
+
was running a release eleven days stale.
|
|
1570
|
+
|
|
1571
|
+
Note that ``inactive`` reaches Imbi only by polling -- here and
|
|
1572
|
+
in :meth:`_latest_status`. GitHub creates the status but emits no
|
|
1573
|
+
``deployment_status`` webhook for it, which its own docs state
|
|
1574
|
+
outright: "A webhook event is not fired for deployment statuses
|
|
1575
|
+
with an inactive state." Confirmed 2026-08-21 against a repo
|
|
1576
|
+
hook subscribed to ``*``, which saw the successor's ``success``
|
|
1577
|
+
and nothing for the auto-inactive written in the same second.
|
|
1578
|
+
So a poll is the only place the state can be handled correctly,
|
|
1579
|
+
and there is no upstream fix to wait for.
|
|
1580
|
+
|
|
1581
|
+
Suppressing the state at source is not the alternative it looks
|
|
1582
|
+
like: ``auto_inactive`` is documented as affecting only
|
|
1583
|
+
"non-transient, non-production" deployments, yet the deployments
|
|
1584
|
+
observed here carry ``production_environment=true`` and were
|
|
1585
|
+
auto-inactivated anyway. The documented carve-out does not
|
|
1586
|
+
describe them, so setting ``auto_inactive=false`` would need an
|
|
1587
|
+
experiment rather than a reading.
|
|
1588
|
+
|
|
1589
|
+
A deployment superseded while still in flight has no terminal
|
|
1590
|
+
entry left once ``inactive`` is skipped, so it reads as whatever
|
|
1591
|
+
it last genuinely reported and the sweeper expires it on age --
|
|
1592
|
+
honest, because it never did finish.
|
|
1593
|
+
|
|
1594
|
+
``log_url`` (and the legacy ``target_url``) on the selected
|
|
1595
|
+
status is what the deploy workflow set to point at its own logs
|
|
1596
|
+
(e.g. the Actions run URL). We carry that as ``run_url`` so the
|
|
1597
|
+
UI can deep-link without having to walk back to the workflow run
|
|
1497
1598
|
through a check-suite join.
|
|
1498
1599
|
"""
|
|
1499
1600
|
async with self._client(ctx, credentials) as client:
|
|
1500
1601
|
resp = await client.get(f'/deployments/{run_id}/statuses')
|
|
1501
1602
|
resp.raise_for_status()
|
|
1502
1603
|
statuses = typing.cast(list[dict[str, typing.Any]], resp.json())
|
|
1503
|
-
|
|
1604
|
+
latest = next(
|
|
1605
|
+
(
|
|
1606
|
+
entry
|
|
1607
|
+
for entry in statuses
|
|
1608
|
+
if str(entry.get('state') or '').lower() != 'inactive'
|
|
1609
|
+
),
|
|
1610
|
+
None,
|
|
1611
|
+
)
|
|
1612
|
+
if latest is None:
|
|
1613
|
+
# Either nothing posted yet, or every entry is an
|
|
1614
|
+
# auto-inactive -- both mean this deployment has told us
|
|
1615
|
+
# nothing about itself.
|
|
1504
1616
|
return DeploymentRun(run_id=str(run_id), status='queued')
|
|
1505
|
-
latest = statuses[0]
|
|
1506
1617
|
state = str(latest.get('state') or '').lower()
|
|
1507
1618
|
status: typing.Literal[
|
|
1508
1619
|
'queued',
|
|
@@ -1520,10 +1631,6 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1520
1631
|
status = 'success'
|
|
1521
1632
|
elif state in {'failure', 'error'}:
|
|
1522
1633
|
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
1634
|
else:
|
|
1528
1635
|
status = 'unknown'
|
|
1529
1636
|
log_url = latest.get('log_url') or latest.get('target_url')
|
|
@@ -1590,6 +1697,235 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1590
1697
|
)
|
|
1591
1698
|
return [observed for group in per_env for observed in group]
|
|
1592
1699
|
|
|
1700
|
+
async def get_environment_state(
|
|
1701
|
+
self,
|
|
1702
|
+
ctx: PluginContext,
|
|
1703
|
+
credentials: dict[str, str],
|
|
1704
|
+
environments: list[str],
|
|
1705
|
+
) -> list[EnvironmentDeploymentState]:
|
|
1706
|
+
"""Report the active deployment per environment.
|
|
1707
|
+
|
|
1708
|
+
Fans out one ``GET /deployments?environment={env}`` call per
|
|
1709
|
+
environment (newest-first, as GitHub orders them) and walks the
|
|
1710
|
+
page fetching each deployment's statuses until one both maps to
|
|
1711
|
+
exactly ``success`` -- not ``pending``, not ``in_progress`` -- and
|
|
1712
|
+
carries no ``inactive`` notice on top. That deployment is the
|
|
1713
|
+
active one.
|
|
1714
|
+
|
|
1715
|
+
Policy note: GitHub can leave several deployments active at once
|
|
1716
|
+
when automatic inactivation is disabled, so "active" cannot be
|
|
1717
|
+
read off the provider's own flag. Imbi's policy is *active = the
|
|
1718
|
+
newest deployment whose latest provider status is success*, which
|
|
1719
|
+
is well-defined either way.
|
|
1720
|
+
|
|
1721
|
+
The walk is bounded by the ``active_scan_limit`` option (see
|
|
1722
|
+
:func:`_active_scan_limit`). Reaching the cap without a success
|
|
1723
|
+
resolves ``unknown``, never ``none``: an older active deployment
|
|
1724
|
+
may sit just past the cap, and reporting ``none`` would have the
|
|
1725
|
+
host clear a pointer that is right.
|
|
1726
|
+
|
|
1727
|
+
Two degraded reads resolve ``error`` for the same reason. A row
|
|
1728
|
+
whose status history would not read (``status_unknown``), or one
|
|
1729
|
+
too malformed to identify at all, leaves the walk unable to say
|
|
1730
|
+
what that deployment did -- throttling blinds every row at once
|
|
1731
|
+
-- and a 404 on the listing itself means the repo moved or the
|
|
1732
|
+
token lost access, not that the environment is empty. An *empty*
|
|
1733
|
+
listing resolves ``unknown``: GitHub says ``[]`` both for an
|
|
1734
|
+
environment never deployed to and for a name it does not
|
|
1735
|
+
recognise, and local slugs reach it unmapped.
|
|
1736
|
+
|
|
1737
|
+
An unreadable row outranks a success found *below* it, so it wins
|
|
1738
|
+
over ``found`` rather than being noted alongside it. The walk
|
|
1739
|
+
stops at the first clean success, which means every row it could
|
|
1740
|
+
not read is newer than that success and may be the deployment
|
|
1741
|
+
actually serving the environment; calling the older one active
|
|
1742
|
+
would have the host write a stale pointer.
|
|
1743
|
+
|
|
1744
|
+
So ``none`` requires positive evidence -- rows read, none of them
|
|
1745
|
+
serving. Everything else the host must read as "keep what you
|
|
1746
|
+
have".
|
|
1747
|
+
"""
|
|
1748
|
+
scan_limit = _active_scan_limit(ctx.integration_options)
|
|
1749
|
+
# Same memoisation as the resync sweep: one triggering-actor
|
|
1750
|
+
# lookup per run and one release lookup per ref, shared across the
|
|
1751
|
+
# parallel per-env fan-out.
|
|
1752
|
+
run_cache: dict[str, tuple[str, str] | None] = {}
|
|
1753
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]] = {}
|
|
1754
|
+
mainline = _mainline_branches(ctx.integration_options)
|
|
1755
|
+
async with self._client(ctx, credentials) as client:
|
|
1756
|
+
return list(
|
|
1757
|
+
await asyncio.gather(
|
|
1758
|
+
*(
|
|
1759
|
+
self._environment_state(
|
|
1760
|
+
client,
|
|
1761
|
+
env,
|
|
1762
|
+
scan_limit,
|
|
1763
|
+
run_cache,
|
|
1764
|
+
release_lookups,
|
|
1765
|
+
mainline,
|
|
1766
|
+
)
|
|
1767
|
+
for env in environments
|
|
1768
|
+
)
|
|
1769
|
+
)
|
|
1770
|
+
)
|
|
1771
|
+
|
|
1772
|
+
async def _environment_state(
|
|
1773
|
+
self,
|
|
1774
|
+
client: httpx.AsyncClient,
|
|
1775
|
+
environment: str,
|
|
1776
|
+
scan_limit: int,
|
|
1777
|
+
run_cache: dict[str, tuple[str, str] | None],
|
|
1778
|
+
release_lookups: dict[str, asyncio.Task[RemoteRelease | None]],
|
|
1779
|
+
mainline: frozenset[str],
|
|
1780
|
+
) -> EnvironmentDeploymentState:
|
|
1781
|
+
"""Resolve one environment's active deployment."""
|
|
1782
|
+
try:
|
|
1783
|
+
resp = await client.get(
|
|
1784
|
+
'/deployments',
|
|
1785
|
+
params={
|
|
1786
|
+
'environment': environment,
|
|
1787
|
+
'per_page': str(scan_limit),
|
|
1788
|
+
},
|
|
1789
|
+
)
|
|
1790
|
+
if resp.status_code == 404:
|
|
1791
|
+
# NOT ``none``, though the listing path treats a 404 as
|
|
1792
|
+
# "nothing to import". Here ``none`` authorizes the host
|
|
1793
|
+
# to clear the environment's current-release pointer, and
|
|
1794
|
+
# GitHub answers 404 for a repo that was renamed or
|
|
1795
|
+
# transferred, or one this installation lost access to --
|
|
1796
|
+
# the very conditions link writeback exists to self-heal.
|
|
1797
|
+
# An unknown *environment* is a 200 with an empty list, so
|
|
1798
|
+
# nothing legitimate is lost by refusing to read a 404 as
|
|
1799
|
+
# an answer.
|
|
1800
|
+
LOGGER.warning(
|
|
1801
|
+
'Active deployment scan got 404 for env=%s; treating '
|
|
1802
|
+
'it as unreadable rather than as "nothing deployed"',
|
|
1803
|
+
environment,
|
|
1804
|
+
)
|
|
1805
|
+
return EnvironmentDeploymentState(
|
|
1806
|
+
environment=environment, active_resolution='error'
|
|
1807
|
+
)
|
|
1808
|
+
resp.raise_for_status()
|
|
1809
|
+
deployments = typing.cast(list[dict[str, typing.Any]], resp.json())
|
|
1810
|
+
except (httpx.HTTPError, ValueError):
|
|
1811
|
+
LOGGER.warning(
|
|
1812
|
+
'Failed to resolve active deployment for env=%s',
|
|
1813
|
+
environment,
|
|
1814
|
+
exc_info=True,
|
|
1815
|
+
)
|
|
1816
|
+
return EnvironmentDeploymentState(
|
|
1817
|
+
environment=environment, active_resolution='error'
|
|
1818
|
+
)
|
|
1819
|
+
active: RemoteDeployment | None = None
|
|
1820
|
+
latest: RemoteDeployment | None = None
|
|
1821
|
+
scanned = 0
|
|
1822
|
+
# Set by any row above the walk's stopping point that we could
|
|
1823
|
+
# not read: a malformed listing entry, or one whose status
|
|
1824
|
+
# history would not load. Because the walk stops at the first
|
|
1825
|
+
# clean success, every such row is *newer* than whatever success
|
|
1826
|
+
# we go on to find, so it may itself be the deployment actually
|
|
1827
|
+
# serving the environment.
|
|
1828
|
+
unresolved = False
|
|
1829
|
+
for deployment in deployments:
|
|
1830
|
+
scanned += 1
|
|
1831
|
+
observed = await self._observe_deployment(
|
|
1832
|
+
client,
|
|
1833
|
+
environment,
|
|
1834
|
+
deployment,
|
|
1835
|
+
run_cache,
|
|
1836
|
+
release_lookups,
|
|
1837
|
+
mainline,
|
|
1838
|
+
)
|
|
1839
|
+
if observed is None:
|
|
1840
|
+
# A row we could not even identify. It is still a row
|
|
1841
|
+
# newer than any success below it, so it has to count as
|
|
1842
|
+
# uncertainty rather than be skipped silently.
|
|
1843
|
+
LOGGER.warning(
|
|
1844
|
+
'Active deployment scan could not identify a row for '
|
|
1845
|
+
'env=%s (id=%r sha=%r); resolving it as unreadable',
|
|
1846
|
+
environment,
|
|
1847
|
+
deployment.get('id'),
|
|
1848
|
+
deployment.get('sha'),
|
|
1849
|
+
)
|
|
1850
|
+
unresolved = True
|
|
1851
|
+
continue
|
|
1852
|
+
if latest is None:
|
|
1853
|
+
latest = observed
|
|
1854
|
+
if observed.status_unknown:
|
|
1855
|
+
# Its status read failed, so this row's ``pending`` is a
|
|
1856
|
+
# fallback. Keep walking -- an older row may still
|
|
1857
|
+
# answer -- but remember that the walk passed something
|
|
1858
|
+
# it could not see.
|
|
1859
|
+
unresolved = True
|
|
1860
|
+
# ``status`` looks past GitHub's ``inactive`` notice on
|
|
1861
|
+
# purpose, so a superseded rollout still reads as the
|
|
1862
|
+
# ``success`` it was. For "what is serving now" that notice
|
|
1863
|
+
# is the answer, not noise: without the ``superseded`` test
|
|
1864
|
+
# a deactivated environment reports its last success as
|
|
1865
|
+
# active forever. Walking on is safe -- an ``inactive``
|
|
1866
|
+
# written because a later deployment took over has that
|
|
1867
|
+
# deployment above it in this same newest-first page.
|
|
1868
|
+
if observed.status == 'success' and not observed.superseded:
|
|
1869
|
+
if unresolved:
|
|
1870
|
+
# A newer row we could not read sits above this
|
|
1871
|
+
# success, so we cannot claim this one is serving.
|
|
1872
|
+
# Stop here and report the uncertainty: the walk has
|
|
1873
|
+
# nothing older left to learn from.
|
|
1874
|
+
break
|
|
1875
|
+
active = observed
|
|
1876
|
+
break
|
|
1877
|
+
# The result set is exhausted only when the walk read every row
|
|
1878
|
+
# GitHub returned *and* GitHub returned fewer than we asked for
|
|
1879
|
+
# (a full page means there is more history past the cap). An
|
|
1880
|
+
# empty listing does not count: GitHub answers 200 with ``[]``
|
|
1881
|
+
# both for an environment that has never been deployed to and
|
|
1882
|
+
# for an environment *name it has never heard of*, and the host
|
|
1883
|
+
# passes local slugs through unmapped. A project whose local
|
|
1884
|
+
# slug is 'prod' against a remote 'production' would otherwise
|
|
1885
|
+
# resolve ``none`` and have its pointer cleared on every sweep.
|
|
1886
|
+
# Clearing needs positive evidence -- rows we read, none of them
|
|
1887
|
+
# serving -- which is the deactivated-environment case.
|
|
1888
|
+
exhausted = (
|
|
1889
|
+
bool(deployments)
|
|
1890
|
+
and scanned == len(deployments)
|
|
1891
|
+
and scanned < scan_limit
|
|
1892
|
+
)
|
|
1893
|
+
resolution: typing.Literal['found', 'none', 'unknown', 'error']
|
|
1894
|
+
if unresolved:
|
|
1895
|
+
# Tested BEFORE ``found`` on purpose. The walk stops at the
|
|
1896
|
+
# first clean success, so every row it could not read is
|
|
1897
|
+
# newer than that success -- and a 403 on the newest
|
|
1898
|
+
# deployment's status hides exactly the deployment most
|
|
1899
|
+
# likely to be serving. Reporting ``found`` here would name
|
|
1900
|
+
# an older release as current and have the host write that
|
|
1901
|
+
# stale pointer. ``error``, not ``none`` and not
|
|
1902
|
+
# ``unknown``: the scan was not capped, it was blinded --
|
|
1903
|
+
# most often by throttling, where every status read fails
|
|
1904
|
+
# and ``none`` would clear every pointer on the project.
|
|
1905
|
+
resolution = 'error'
|
|
1906
|
+
elif active is not None:
|
|
1907
|
+
resolution = 'found'
|
|
1908
|
+
elif exhausted:
|
|
1909
|
+
resolution = 'none'
|
|
1910
|
+
else:
|
|
1911
|
+
resolution = 'unknown'
|
|
1912
|
+
LOGGER.info(
|
|
1913
|
+
'Active deployment scan env=%s deployments_scanned=%d '
|
|
1914
|
+
'unresolved_above_success=%s scan_exhausted=%s '
|
|
1915
|
+
'active_resolution=%s',
|
|
1916
|
+
environment,
|
|
1917
|
+
scanned,
|
|
1918
|
+
unresolved,
|
|
1919
|
+
exhausted,
|
|
1920
|
+
resolution,
|
|
1921
|
+
)
|
|
1922
|
+
return EnvironmentDeploymentState(
|
|
1923
|
+
environment=environment,
|
|
1924
|
+
active=active,
|
|
1925
|
+
latest=latest,
|
|
1926
|
+
active_resolution=resolution,
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1593
1929
|
async def get_release_notes(
|
|
1594
1930
|
self,
|
|
1595
1931
|
ctx: PluginContext,
|
|
@@ -1680,9 +2016,8 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1680
2016
|
created_at = _parse_iso(deployment.get('created_at')) or (
|
|
1681
2017
|
datetime.datetime.now(datetime.UTC)
|
|
1682
2018
|
)
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
)
|
|
2019
|
+
read = await self._latest_status(client, str(deployment_id))
|
|
2020
|
+
status, status_url = read.status, read.log_url
|
|
1686
2021
|
ref_value = deployment.get('ref')
|
|
1687
2022
|
description = deployment.get('description')
|
|
1688
2023
|
release_notes = (
|
|
@@ -1731,6 +2066,8 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1731
2066
|
release_notes=release_notes,
|
|
1732
2067
|
creator=creator_login,
|
|
1733
2068
|
creator_subject=creator_subject,
|
|
2069
|
+
superseded=read.superseded,
|
|
2070
|
+
status_unknown=read.unreadable,
|
|
1734
2071
|
)
|
|
1735
2072
|
|
|
1736
2073
|
async def _resolve_triggering_actor(
|
|
@@ -1828,6 +2165,13 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1828
2165
|
credits with the release and ``author_subject`` its numeric user
|
|
1829
2166
|
id, which the host resolves to an Imbi user through the identity
|
|
1830
2167
|
plugins on the same service.
|
|
2168
|
+
|
|
2169
|
+
A 401 degrades here too, like every other failure, rather than
|
|
2170
|
+
propagating: release notes are *enrichment*, so losing them must
|
|
2171
|
+
never fail the caller -- the same rule
|
|
2172
|
+
:meth:`_resolve_triggering_actor` applies to attribution. The
|
|
2173
|
+
status read is deliberately not treated this way, because there
|
|
2174
|
+
the answer itself is what a 401 hides.
|
|
1831
2175
|
"""
|
|
1832
2176
|
if _releases_forbidden(client):
|
|
1833
2177
|
return None
|
|
@@ -1835,7 +2179,7 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1835
2179
|
resp = await client.get(
|
|
1836
2180
|
f'/releases/tags/{urllib.parse.quote(ref, safe="")}'
|
|
1837
2181
|
)
|
|
1838
|
-
except httpx.HTTPError:
|
|
2182
|
+
except (httpx.HTTPError, PluginAuthenticationFailed):
|
|
1839
2183
|
return None
|
|
1840
2184
|
if resp.status_code == 403:
|
|
1841
2185
|
_record_releases_forbidden(client)
|
|
@@ -1897,8 +2241,15 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1897
2241
|
|
|
1898
2242
|
async def _latest_status(
|
|
1899
2243
|
self, client: httpx.AsyncClient, deployment_id: str
|
|
1900
|
-
) ->
|
|
1901
|
-
"""Return the canonical event status
|
|
2244
|
+
) -> _StatusRead:
|
|
2245
|
+
"""Return the canonical event status, log URL, and retirement.
|
|
2246
|
+
|
|
2247
|
+
The third element is ``True`` when the newest status entry is
|
|
2248
|
+
``inactive``: the deployment's own outcome (the first element)
|
|
2249
|
+
looks past that notice, so this is the only place the caller can
|
|
2250
|
+
learn that GitHub has since retired it. Both readings are
|
|
2251
|
+
needed and neither substitutes for the other -- "what did this
|
|
2252
|
+
rollout do" is a different question from "is it serving now".
|
|
1902
2253
|
|
|
1903
2254
|
Falls back to ``'pending'`` whenever the deploy workflow has
|
|
1904
2255
|
not yet posted a status: a freshly-created deployment with no
|
|
@@ -1906,26 +2257,79 @@ class GitHubDeployment(DeploymentCapability):
|
|
|
1906
2257
|
not started, and ``pending`` is the host's vocabulary for
|
|
1907
2258
|
both. Network / parse errors degrade the same way so resync
|
|
1908
2259
|
is never blocked by a single noisy row.
|
|
2260
|
+
|
|
2261
|
+
An ``inactive`` entry is looked *past* when something sits
|
|
2262
|
+
beneath it. GitHub writes one on a deployment when a later one
|
|
2263
|
+
supersedes it, so it reports the successor's arrival rather than
|
|
2264
|
+
this deployment's outcome -- and every deployment except an
|
|
2265
|
+
environment's newest carries one. Reading it verbatim therefore
|
|
2266
|
+
relabelled whole deployment histories as ``rolled_back``, each
|
|
2267
|
+
node's ``history`` showing the ``success`` it overwrote; ~14k
|
|
2268
|
+
nodes in the production graph were in that state before this
|
|
2269
|
+
skip existed.
|
|
2270
|
+
|
|
2271
|
+
An ``inactive`` with nothing beneath it still reports
|
|
2272
|
+
``rolled_back``: that deployment was superseded without ever
|
|
2273
|
+
reporting on itself, and ``rolled_back`` is the accurate
|
|
2274
|
+
terminal for it. The distinction is the whole point -- skip the
|
|
2275
|
+
retirement notice when it is hiding a real outcome, keep it when
|
|
2276
|
+
it is the only thing we know.
|
|
2277
|
+
|
|
2278
|
+
This is why the page size is not 1. A single-status window
|
|
2279
|
+
cannot see past an ``inactive`` to the ``success`` underneath,
|
|
2280
|
+
which is what made the misread unavoidable.
|
|
1909
2281
|
"""
|
|
1910
2282
|
try:
|
|
1911
2283
|
resp = await client.get(
|
|
1912
2284
|
f'/deployments/{deployment_id}/statuses',
|
|
1913
|
-
params={'per_page': '
|
|
2285
|
+
params={'per_page': '10'},
|
|
1914
2286
|
)
|
|
1915
2287
|
except httpx.HTTPError:
|
|
1916
|
-
return
|
|
2288
|
+
return _StatusRead(
|
|
2289
|
+
'pending', None, superseded=False, unreadable=True
|
|
2290
|
+
)
|
|
1917
2291
|
if resp.status_code != 200:
|
|
1918
|
-
|
|
2292
|
+
# 403/429 land here, which is how a throttled scan used to
|
|
2293
|
+
# read every row as ``pending`` and conclude that nothing was
|
|
2294
|
+
# deployed. The status is still ``pending`` for resync; the
|
|
2295
|
+
# flag is what stops a currency decision resting on it.
|
|
2296
|
+
LOGGER.warning(
|
|
2297
|
+
'Deployment %s statuses answered %d; status unread',
|
|
2298
|
+
deployment_id,
|
|
2299
|
+
resp.status_code,
|
|
2300
|
+
)
|
|
2301
|
+
return _StatusRead(
|
|
2302
|
+
'pending', None, superseded=False, unreadable=True
|
|
2303
|
+
)
|
|
1919
2304
|
try:
|
|
1920
2305
|
statuses = typing.cast(list[dict[str, typing.Any]], resp.json())
|
|
1921
2306
|
except ValueError:
|
|
1922
|
-
return
|
|
2307
|
+
return _StatusRead(
|
|
2308
|
+
'pending', None, superseded=False, unreadable=True
|
|
2309
|
+
)
|
|
1923
2310
|
if not statuses:
|
|
1924
|
-
|
|
1925
|
-
|
|
2311
|
+
# Read fine and there is genuinely nothing: a deployment
|
|
2312
|
+
# whose workflow has not posted yet. Not unreadable.
|
|
2313
|
+
return _StatusRead(
|
|
2314
|
+
'pending', None, superseded=False, unreadable=False
|
|
2315
|
+
)
|
|
2316
|
+
superseded = str(statuses[0].get('state') or '').lower() == 'inactive'
|
|
2317
|
+
latest = next(
|
|
2318
|
+
(
|
|
2319
|
+
entry
|
|
2320
|
+
for entry in statuses
|
|
2321
|
+
if str(entry.get('state') or '').lower() != 'inactive'
|
|
2322
|
+
),
|
|
2323
|
+
statuses[0],
|
|
2324
|
+
)
|
|
1926
2325
|
state = str(latest.get('state') or '').lower()
|
|
1927
2326
|
log_url = latest.get('log_url') or latest.get('target_url')
|
|
1928
|
-
return
|
|
2327
|
+
return _StatusRead(
|
|
2328
|
+
_to_event_status(state),
|
|
2329
|
+
str(log_url) if log_url else None,
|
|
2330
|
+
superseded=superseded,
|
|
2331
|
+
unreadable=False,
|
|
2332
|
+
)
|
|
1929
2333
|
|
|
1930
2334
|
|
|
1931
2335
|
_RUN_ID_RE = re.compile(r'/actions/runs/(\d+)')
|