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.
Files changed (27) hide show
  1. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/deployment.py +439 -35
  4. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/plugin.py +18 -0
  5. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_deployment.py +674 -5
  6. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/.gitignore +0 -0
  7. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/README.md +0 -0
  8. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/moon.yml +0 -0
  9. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/README.md +0 -0
  10. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/__init__.py +0 -0
  11. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  12. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_hosts.py +0 -0
  13. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/_repos.py +0 -0
  14. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/commits.py +0 -0
  15. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/doctor.py +0 -0
  16. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/identity.py +0 -0
  17. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  18. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  19. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/src/imbi/plugins/github/py.typed +0 -0
  20. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/__init__.py +0 -0
  21. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_commits.py +0 -0
  22. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.27.0 → imbi_plugin_github-2.29.0}/tests/test_pull_requests.py +0 -0
  27. {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.27.0
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.27.0
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.27.0"
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.27.0",
21
+ "imbi-common[databases]==2.29.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -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 an empty, complete listing.
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, client: httpx.AsyncClient, commit_sha: str
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
- items = list(notes.items())
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(notes))
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 latest entry wins. An empty list means the
1490
- deploy workflow hasn't posted anything yet, which Imbi surfaces
1491
- as ``'queued'``.
1492
-
1493
- ``log_url`` (and the legacy ``target_url``) on the latest status
1494
- is what the deploy workflow set to point at its own logs (e.g.
1495
- the Actions run URL). We carry that as ``run_url`` so the UI
1496
- can deep-link without having to walk back to the workflow run
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
- if not statuses:
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
- status, status_url = await self._latest_status(
1684
- client, str(deployment_id)
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
- ) -> tuple[DeploymentEventStatus, str | None]:
1901
- """Return the canonical event status + workflow log URL.
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': '1'},
2285
+ params={'per_page': '10'},
1914
2286
  )
1915
2287
  except httpx.HTTPError:
1916
- return 'pending', None
2288
+ return _StatusRead(
2289
+ 'pending', None, superseded=False, unreadable=True
2290
+ )
1917
2291
  if resp.status_code != 200:
1918
- return 'pending', None
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 'pending', None
2307
+ return _StatusRead(
2308
+ 'pending', None, superseded=False, unreadable=True
2309
+ )
1923
2310
  if not statuses:
1924
- return 'pending', None
1925
- latest = statuses[0]
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 _to_event_status(state), str(log_url) if log_url else None
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+)')