Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
604db26a11 | ||
|
|
2ea38931b7 |
@@ -21,9 +21,6 @@ name: Post-merge
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
# Implements: REQ-1 — manual recovery when a [skip ci] squash title
|
|
||||||
# suppresses the push-triggered post-merge run.
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: post-merge-${{ github.ref }}
|
group: post-merge-${{ github.ref }}
|
||||||
|
|||||||
@@ -2,18 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [0.56.0] - 2026-09-30
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- *(molecule)* Log lease owner and UTC expiry on emission
|
|
||||||
|
|
||||||
## [0.55.5] - 2026-09-28
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Strip [skip ci] tokens and prefer newest commit in squash titles
|
|
||||||
|
|
||||||
## [0.55.4] - 2026-09-23
|
## [0.55.4] - 2026-09-23
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.56.0",
|
"devx>=0.55.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (for example, `"devx==0.56.0"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.55.4"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.56.0,<0.57"`).
|
> (for example, `"devx>=0.55.4,<0.56"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.56.0",
|
"devx>=0.55.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin a specific version if needed: `"devx==0.56.0"` or `"devx>=0.56.0,<0.57"`.
|
Pin a specific version if needed: `"devx==0.55.4"` or `"devx>=0.55.4,<0.56"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# DEVX-179: auto-merge must never select `[skip ci]` squash titles
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
PR #351 squash-merged as `DEVX-178: chore: update badge URLs to commit
|
|
||||||
4422c70b [skip ci]`. `extract_conventional_msg` picked a commit subject
|
|
||||||
carrying `[skip ci]` — a master badge commit surfaced in the PR's commit
|
|
||||||
list after force-push/amend rewrote branch history. The resulting merge
|
|
||||||
commit suppressed the post-merge push run: no release, no publish, no
|
|
||||||
wiki sync, no Vikunja close.
|
|
||||||
|
|
||||||
PR #352 shows the secondary defect: two `ci:` commits on the branch and
|
|
||||||
the older one won the tie, so the squash title described a throwaway
|
|
||||||
retrigger commit instead of the real change.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
REQ-1: Subjects containing `[skip ci]`, `[ci skip]`, or `[skip actions]`
|
|
||||||
(case-insensitive) are ineligible merge titles. They are excluded before
|
|
||||||
priority scoring, so a badge/chore/`[skip ci]` commit can never suppress
|
|
||||||
the post-merge pipeline again.
|
|
||||||
|
|
||||||
REQ-2: Equal-priority ties resolve to the newest commit in the returned
|
|
||||||
list (Gitea returns PR commits newest-first; iterate in returned order
|
|
||||||
and keep the first best candidate).
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
|
|
||||||
- `test_auto_merge.py`: commits `[ci retrigger, badge-chore-skipci]` →
|
|
||||||
the `ci` subject wins; commits `[older-ci, newer-ci]` → newest wins;
|
|
||||||
all commits `[skip ci]` → falls back to newest non-skipped subject.
|
|
||||||
- `make pytest-cov` (100% gate), `make lint-all`.
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
|
|
||||||
Merge as `fix:` → post-merge cuts a release that also ships DEVX-178's
|
|
||||||
PLAYBOOK_ROLE_MAP entry.
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
Revert the squash commit; extract behavior returns to the previous
|
|
||||||
(unhardened) selection.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] REQ-1: `[skip ci]`/`[ci skip]`/`[skip actions]` subjects are never
|
|
||||||
selected.
|
|
||||||
- [x] REQ-2: Equal-priority ties pick the newest commit.
|
|
||||||
- [x] Unit tests cover both; 100% coverage maintained.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# DEVX-180: Add workflow_dispatch trigger to post-merge workflow
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
When auto-merge produces a `[skip ci]` squash title (DEVX-179), the
|
|
||||||
post-merge push run is suppressed — release, publish, wiki sync, Vikunja
|
|
||||||
close, and badges are all skipped with no manual recovery path, because
|
|
||||||
`post-merge.yml` only triggers on `push`.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
REQ-1: Add `workflow_dispatch:` to the `on:` block of
|
|
||||||
`.gitea/workflows/post-merge.yml` so a missed or skipped post-merge run
|
|
||||||
can be dispatched manually on master.
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
|
|
||||||
- `make workflow-check` (actionlint + act_runner dryrun) validates the
|
|
||||||
trigger syntax.
|
|
||||||
- Post-merge: dispatch the workflow manually once and confirm it runs
|
|
||||||
on master.
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
|
|
||||||
Merge → post-merge push run fires normally → then dispatch
|
|
||||||
`post-merge.yml` on master to recover the suppressed DEVX-178 release.
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
Revert the one-line trigger addition.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] REQ-1: `workflow_dispatch` present in `post-merge.yml` `on:` block;
|
|
||||||
actionlint and act_runner dryrun pass.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# DEVX-181: Emit runner resource leases for CI molecule jobs (S08 producer side)
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
GRM-173 (S08) shipped consumer-side runner leases: `runner-cleanup.sh`
|
|
||||||
honors `org.oblachno.lease-until` / `org.oblachno.owner` labels on
|
|
||||||
containers, volumes, and networks. But no CI producer emits them:
|
|
||||||
molecule containers/networks created on the runner's host rootless Docker
|
|
||||||
socket carry only `owner=molecule`, so a pressure/critical cleanup can
|
|
||||||
still wipe a molecule object that is stopped for a moment mid-run.
|
|
||||||
Docker labels cannot be added post-creation — producers must emit them
|
|
||||||
at create time.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
REQ-1: New `devx.molecule.lease` module computing `MOLECULE_LEASE_UNTIL`
|
|
||||||
(epoch = now + TTL, default 4h) and `MOLECULE_OWNER` (`run-<id>-<job>`
|
|
||||||
from GITEA_*/GITHUB_* env, else `local-<hostname>`), with
|
|
||||||
`emit_github_env()` appending them to `$GITHUB_ENV` and a Click CLI
|
|
||||||
printing `KEY=value` lines.
|
|
||||||
REQ-2: `start_docker.main()` emits the lease env after a successful
|
|
||||||
Docker start — every molecule CI job already calls it, so no workflow
|
|
||||||
edits are needed.
|
|
||||||
REQ-3: Consumers (infra/grm `molecule.yml`) add platform labels
|
|
||||||
`org.oblachno.lease-until: "${MOLECULE_LEASE_UNTIL:-0}"` and
|
|
||||||
`org.oblachno.owner: "${MOLECULE_OWNER:-molecule-local}"` — `0` is
|
|
||||||
already expired, so local/unset runs keep today's unleased behavior.
|
|
||||||
Downstream changes land in the infra and grm repos.
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
- `tests/unit/test_molecule_lease.py` covers `lease_owner` env precedence
|
|
||||||
and fallbacks, `lease_env` arithmetic, `emit_github_env` write/no-op,
|
|
||||||
and the CLI — 100% coverage enforced by `make pytest-cov`.
|
|
||||||
- Existing `test_start_docker.py` still green (emission is additive).
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
- Merge via auto-merge → post-merge releases and publishes devx; infra
|
|
||||||
and grm pin bumps consume it via their molecule.yml label additions.
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
- Revert the merge; `start_docker` stops exporting the vars and molecule
|
|
||||||
interpolation falls back to expired leases (`:-0`).
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [x] REQ-1: `devx.molecule.lease` computes both env vars and writes them
|
|
||||||
to `$GITHUB_ENV` when set.
|
|
||||||
- [x] REQ-2: `start_docker` exports lease env on success without changing
|
|
||||||
its existing behavior contract.
|
|
||||||
- [x] REQ-3: the label contract (`org.oblachno.lease-until`,
|
|
||||||
`org.oblachno.owner`) is exposed as module constants for consumers.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# DEVX-182: feat(molecule): release lease emission (ships DEVX-181)
|
|
||||||
|
|
||||||
- Task: DEVX-182
|
|
||||||
- Status: draft
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
DEVX-181 landed on master but was never released: the squash subject
|
|
||||||
came out `DEVX-181: ci: retrigger validation` because the branch's last
|
|
||||||
commit was an empty `ci:` retrigger. git-cliff found no bump-worthy
|
|
||||||
commit, so `v0.55.5` is still latest and the S08 lease producer is
|
|
||||||
unshipped. A `feat:` merge is needed to cut the release — and while
|
|
||||||
shipping, the emission log line is upgraded from a bare count to the
|
|
||||||
actual lease owner and UTC expiry, which is what an operator needs when
|
|
||||||
debugging a cleanup race.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
REQ-1: `start_docker.main()` computes `lease_env()` once and logs
|
|
||||||
`owner=` + ISO-8601 `until=` alongside the export confirmation. The
|
|
||||||
single call also removes a latent second-boundary inconsistency where
|
|
||||||
`emit_github_env` and the count message each re-derived `now`.
|
|
||||||
|
|
||||||
REQ-2: Merge squash commit carries `feat(molecule):` so git-cliff cuts
|
|
||||||
the next minor release containing DEVX-181's emitter and this change.
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
|
|
||||||
- Update `test_emits_lease_on_success` + `test_prints_and_exports` for
|
|
||||||
the new log format; keep 100 % coverage.
|
|
||||||
- `make pre-push` — full devx suite green.
|
|
||||||
- Post-merge: verify a `v*` tag > v0.55.5 exists and
|
|
||||||
`pip index versions devx` (Gitea PyPI) lists it.
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
|
|
||||||
- Auto-merge; post-merge release job tags + publishes.
|
|
||||||
- Infra (OBL-INFRA-658) and GRM (GRM-174) consume the new pin.
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
- Revert merge; the unreleased module remains dormant regardless.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] REQ-1: emission log includes owner and ISO-8601 expiry; lease env
|
|
||||||
computed once — covered by updated unit tests.
|
|
||||||
- [x] REQ-2: merge commit uses `feat(molecule):` so the release job
|
|
||||||
cuts a version bump.
|
|
||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.56.0",
|
"devx>=0.55.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.56.0",
|
"devx>=0.55.4",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
|
|||||||
molecule testing helpers for Ansible projects.
|
molecule testing helpers for Ansible projects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.56.0"
|
__version__ = "0.55.4"
|
||||||
|
|||||||
@@ -184,31 +184,22 @@ def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Subjects carrying these tokens suppress post-merge CI entirely — strip them
|
|
||||||
# before a commit message can become the squash title (see DEVX-179).
|
|
||||||
_SKIP_CI_RE = re.compile(r"\s*\[(?:ci skip|skip ci|skip actions|actions skip|skip)\]", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||||
"""Extract the conventional commit message from PR commits.
|
"""Extract the conventional commit message from PR commits.
|
||||||
|
|
||||||
Picks the highest-priority conventional commit message from the PR.
|
Picks the highest-priority conventional commit message from the PR.
|
||||||
Priority: feat > fix > refactor > docs > chore > other. The Gitea
|
Priority: feat > fix > refactor > docs > chore > other.
|
||||||
``/pulls/{n}/commits`` endpoint returns commits newest-first, so the
|
Falls back to the newest commit message if none match.
|
||||||
first best-scoring subject wins equal-priority ties.
|
|
||||||
``[skip ci]``-style tokens are stripped from every candidate so the
|
|
||||||
merge title can never suppress the post-merge release pipeline.
|
|
||||||
"""
|
"""
|
||||||
priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1}
|
priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1}
|
||||||
best_msg = ""
|
best_msg = ""
|
||||||
best_score = 0
|
best_score = 0
|
||||||
for commit in commits:
|
for commit in reversed(commits):
|
||||||
commit_info = commit.get("commit", {})
|
commit_info = commit.get("commit", {})
|
||||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||||
# Strip any leading task ID prefix (e.g. "OBL-INFRA-364: fix: ...") so
|
# Strip any leading task ID prefix (e.g. "OBL-INFRA-364: fix: ...") so
|
||||||
# conventional commit matching works on the remainder; drop CI-skip
|
# conventional commit matching works on the remainder.
|
||||||
# tokens so they never reach the merge title.
|
stripped = _TASK_ID_PREFIX_RE.sub("", message)
|
||||||
stripped = _SKIP_CI_RE.sub("", _TASK_ID_PREFIX_RE.sub("", message)).strip()
|
|
||||||
m = CONVENTIONAL_RE.match(stripped)
|
m = CONVENTIONAL_RE.match(stripped)
|
||||||
if m:
|
if m:
|
||||||
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
|
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
|
||||||
@@ -218,11 +209,11 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
|||||||
best_msg = stripped
|
best_msg = stripped
|
||||||
if best_msg:
|
if best_msg:
|
||||||
return best_msg
|
return best_msg
|
||||||
# Fallback: the newest commit's first line (newest-first API order).
|
# Fallback: use the newest commit's first line (strip task ID prefix if present)
|
||||||
if commits:
|
if commits:
|
||||||
commit_info = commits[0].get("commit", {})
|
commit_info = commits[-1].get("commit", {})
|
||||||
raw = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
raw = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||||
return _SKIP_CI_RE.sub("", _TASK_ID_PREFIX_RE.sub("", raw)).strip()
|
return _TASK_ID_PREFIX_RE.sub("", raw)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
"""Emit bounded runner-resource lease env for CI molecule jobs.
|
|
||||||
|
|
||||||
Producer side of the S08 lease contract (consumer: GRM-173
|
|
||||||
``runner-cleanup.sh``). The runner's scoped cleanup honors
|
|
||||||
``org.oblachno.lease-until`` (epoch seconds) and ``org.oblachno.owner``
|
|
||||||
labels on containers, volumes, and networks. Molecule runs on the runner
|
|
||||||
host's rootless Docker socket, so objects it creates must carry the lease
|
|
||||||
labels at creation time — Docker labels cannot be added post-hoc.
|
|
||||||
|
|
||||||
This module computes ``MOLECULE_LEASE_UNTIL`` and ``MOLECULE_OWNER``,
|
|
||||||
which ``molecule.yml`` platform ``labels:`` entries consume via
|
|
||||||
``${MOLECULE_LEASE_UNTIL:-0}`` / ``${MOLECULE_OWNER:-molecule-local}``
|
|
||||||
interpolation, and appends them to ``$GITHUB_ENV`` so every later CI step
|
|
||||||
inherits them. A lease-until of ``0`` (the interpolation default for
|
|
||||||
local runs) is already expired, keeping today's unleased behavior.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
#: Docker label contract consumed by runner-cleanup.sh (GRM-173).
|
|
||||||
LEASE_UNTIL_LABEL = "org.oblachno.lease-until"
|
|
||||||
OWNER_LABEL = "org.oblachno.owner"
|
|
||||||
|
|
||||||
#: Env vars consumed by molecule.yml platform ``labels:`` entries.
|
|
||||||
LEASE_UNTIL_ENV = "MOLECULE_LEASE_UNTIL"
|
|
||||||
OWNER_ENV = "MOLECULE_OWNER"
|
|
||||||
|
|
||||||
#: Default lease duration — covers the longest molecule job window.
|
|
||||||
DEFAULT_TTL_SECONDS = 4 * 3600
|
|
||||||
|
|
||||||
|
|
||||||
def lease_owner() -> str:
|
|
||||||
"""Identify the owning CI run for the ``org.oblachno.owner`` label.
|
|
||||||
|
|
||||||
act_runner exposes GitHub-compatible ``GITHUB_*`` env vars;
|
|
||||||
``GITEA_*`` names are checked first for forward compatibility.
|
|
||||||
"""
|
|
||||||
run_id = os.environ.get("GITEA_RUN_ID") or os.environ.get("GITHUB_RUN_ID")
|
|
||||||
job = os.environ.get("GITEA_JOB") or os.environ.get("GITHUB_JOB")
|
|
||||||
if run_id:
|
|
||||||
return f"run-{run_id}-{job or 'job'}"
|
|
||||||
return f"local-{socket.gethostname()}"
|
|
||||||
|
|
||||||
|
|
||||||
def lease_env(ttl: int = DEFAULT_TTL_SECONDS, now: int | None = None) -> dict[str, str]:
|
|
||||||
"""Return the lease env dict for the current run."""
|
|
||||||
base = int(time.time()) if now is None else now
|
|
||||||
return {
|
|
||||||
LEASE_UNTIL_ENV: str(base + ttl),
|
|
||||||
OWNER_ENV: lease_owner(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def emit_github_env(env: dict[str, str]) -> int:
|
|
||||||
"""Append lease env to ``$GITHUB_ENV``; return lines written (0 if unset)."""
|
|
||||||
github_env = os.environ.get("GITHUB_ENV")
|
|
||||||
if not github_env:
|
|
||||||
return 0
|
|
||||||
with open(github_env, "a", encoding="utf-8") as f:
|
|
||||||
for key, value in env.items():
|
|
||||||
f.write(f"{key}={value}\n")
|
|
||||||
return len(env)
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option(
|
|
||||||
"--ttl",
|
|
||||||
default=DEFAULT_TTL_SECONDS,
|
|
||||||
type=int,
|
|
||||||
help=f"Lease duration in seconds (default: {DEFAULT_TTL_SECONDS}).",
|
|
||||||
)
|
|
||||||
def main(ttl: int) -> None:
|
|
||||||
"""Print lease env vars and export them to $GITHUB_ENV when set."""
|
|
||||||
env = lease_env(ttl)
|
|
||||||
for key, value in env.items():
|
|
||||||
click.echo(f"{key}={value}")
|
|
||||||
if emit_github_env(env):
|
|
||||||
click.echo(f"Exported {len(env)} lease vars to GITHUB_ENV")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -31,12 +31,10 @@ import subprocess # nosec B404
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
from devx.molecule.lease import LEASE_UNTIL_ENV, OWNER_ENV, emit_github_env, lease_env
|
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
DOCKER_SOCK = "/var/run/docker.sock"
|
DOCKER_SOCK = "/var/run/docker.sock"
|
||||||
@@ -386,15 +384,6 @@ def main(timeout: int) -> None:
|
|||||||
with open(github_env, "a", encoding="utf-8") as f:
|
with open(github_env, "a", encoding="utf-8") as f:
|
||||||
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
||||||
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
||||||
# Implements: REQ-2 (DEVX-181) — emit the S08 producer lease env so
|
|
||||||
# molecule-created objects carry org.oblachno.lease-until/owner
|
|
||||||
# labels that runner-cleanup.sh honors.
|
|
||||||
# Implements: REQ-1 (DEVX-182) — one lease_env() call; log the
|
|
||||||
# owner and UTC expiry an operator needs for cleanup debugging.
|
|
||||||
env = lease_env()
|
|
||||||
if emit_github_env(env):
|
|
||||||
until = datetime.fromtimestamp(int(env[LEASE_UNTIL_ENV]), tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
click.echo(f"Exported lease vars to GITHUB_ENV (owner={env[OWNER_ENV]}, until={until})")
|
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -232,40 +232,6 @@ class TestExtractConventionalMsg:
|
|||||||
]
|
]
|
||||||
assert extract_conventional_msg(commits) == "random message"
|
assert extract_conventional_msg(commits) == "random message"
|
||||||
|
|
||||||
def test_skip_ci_subjects_are_ineligible(self) -> None:
|
|
||||||
"""[skip ci] commits must never become the merge title (REQ-1)."""
|
|
||||||
commits = [
|
|
||||||
{"commit": {"message": "ci: retrigger validation"}},
|
|
||||||
{"commit": {"message": "chore: update badge URLs to commit abc123 [skip ci]"}},
|
|
||||||
]
|
|
||||||
assert extract_conventional_msg(commits) == "ci: retrigger validation"
|
|
||||||
|
|
||||||
def test_skip_ci_variants_excluded(self) -> None:
|
|
||||||
"""All skip-token spellings are ineligible."""
|
|
||||||
for token in ("[skip ci]", "[ci skip]", "[skip actions]", "[actions skip]", "[SKIP CI]"):
|
|
||||||
commits = [
|
|
||||||
{"commit": {"message": "feat: real change"}},
|
|
||||||
{"commit": {"message": f"chore: noise {token}"}},
|
|
||||||
]
|
|
||||||
assert extract_conventional_msg(commits) == "feat: real change"
|
|
||||||
|
|
||||||
def test_tie_prefers_newest_commit(self) -> None:
|
|
||||||
"""Equal-priority ties resolve to the newest commit (REQ-2); the
|
|
||||||
API returns commits newest-first."""
|
|
||||||
commits = [
|
|
||||||
{"commit": {"message": "ci: add workflow_dispatch trigger"}},
|
|
||||||
{"commit": {"message": "ci: retrigger validation"}},
|
|
||||||
]
|
|
||||||
assert extract_conventional_msg(commits) == "ci: add workflow_dispatch trigger"
|
|
||||||
|
|
||||||
def test_all_skip_ci_fallback_strips_token(self) -> None:
|
|
||||||
"""When every commit carries [skip ci], the token is stripped so
|
|
||||||
the merge still triggers post-merge."""
|
|
||||||
commits = [
|
|
||||||
{"commit": {"message": "chore: noise [skip ci]"}},
|
|
||||||
]
|
|
||||||
assert extract_conventional_msg(commits) == "chore: noise"
|
|
||||||
|
|
||||||
|
|
||||||
# -- run_cmd --
|
# -- run_cmd --
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
"""Unit tests for devx.molecule.lease."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from unittest.mock import MagicMock, mock_open, patch
|
|
||||||
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from devx.molecule.lease import (
|
|
||||||
DEFAULT_TTL_SECONDS,
|
|
||||||
LEASE_UNTIL_ENV,
|
|
||||||
OWNER_ENV,
|
|
||||||
emit_github_env,
|
|
||||||
lease_env,
|
|
||||||
lease_owner,
|
|
||||||
main,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLeaseOwner:
|
|
||||||
def test_gitea_env_preferred(self) -> None:
|
|
||||||
with patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{"GITEA_RUN_ID": "123", "GITEA_JOB": "tests", "GITHUB_RUN_ID": "999"},
|
|
||||||
):
|
|
||||||
assert lease_owner() == "run-123-tests"
|
|
||||||
|
|
||||||
def test_github_env_fallback(self) -> None:
|
|
||||||
env = {"GITHUB_RUN_ID": "777", "GITHUB_JOB": "molecule"}
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
assert lease_owner() == "run-777-molecule"
|
|
||||||
|
|
||||||
def test_job_defaults_when_missing(self) -> None:
|
|
||||||
with patch.dict(os.environ, {"GITHUB_RUN_ID": "5"}, clear=True):
|
|
||||||
assert lease_owner() == "run-5-job"
|
|
||||||
|
|
||||||
def test_local_fallback(self) -> None:
|
|
||||||
with (
|
|
||||||
patch.dict(os.environ, {}, clear=True),
|
|
||||||
patch("devx.molecule.lease.socket.gethostname", return_value="devbox"),
|
|
||||||
):
|
|
||||||
assert lease_owner() == "local-devbox"
|
|
||||||
|
|
||||||
|
|
||||||
class TestLeaseEnv:
|
|
||||||
def test_arithmetic(self) -> None:
|
|
||||||
env = lease_env(ttl=3600, now=1000)
|
|
||||||
assert env[LEASE_UNTIL_ENV] == str(1000 + 3600)
|
|
||||||
assert OWNER_ENV in env
|
|
||||||
|
|
||||||
def test_default_now_uses_time(self) -> None:
|
|
||||||
with patch("devx.molecule.lease.time.time", return_value=2000.9):
|
|
||||||
env = lease_env(ttl=60)
|
|
||||||
assert env[LEASE_UNTIL_ENV] == str(2000 + 60)
|
|
||||||
|
|
||||||
def test_default_ttl(self) -> None:
|
|
||||||
env = lease_env(now=0)
|
|
||||||
assert env[LEASE_UNTIL_ENV] == str(DEFAULT_TTL_SECONDS)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmitGithubEnv:
|
|
||||||
def test_writes_lines(self) -> None:
|
|
||||||
m = mock_open()
|
|
||||||
with (
|
|
||||||
patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env"}, clear=False),
|
|
||||||
patch("builtins.open", m),
|
|
||||||
):
|
|
||||||
written = emit_github_env({"A": "1", "B": "2"})
|
|
||||||
assert written == 2
|
|
||||||
m.assert_called_once_with("/tmp/env", "a", encoding="utf-8")
|
|
||||||
m().write.assert_any_call("A=1\n")
|
|
||||||
m().write.assert_any_call("B=2\n")
|
|
||||||
|
|
||||||
def test_noop_without_env(self) -> None:
|
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
|
||||||
assert emit_github_env({"A": "1"}) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCli:
|
|
||||||
def test_prints_and_exports(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
m = mock_open()
|
|
||||||
with (
|
|
||||||
patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env", "GITHUB_RUN_ID": "42"}, clear=True),
|
|
||||||
patch("builtins.open", m),
|
|
||||||
patch("devx.molecule.lease.time.time", return_value=100.0),
|
|
||||||
):
|
|
||||||
result = runner.invoke(main, ["--ttl", "600"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert f"{LEASE_UNTIL_ENV}=700" in result.output
|
|
||||||
assert f"{OWNER_ENV}=run-42-job" in result.output
|
|
||||||
assert "Exported 2 lease vars" in result.output
|
|
||||||
|
|
||||||
def test_prints_without_github_env(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
with (
|
|
||||||
patch.dict(os.environ, {"GITHUB_RUN_ID": "9", "GITHUB_JOB": "tests"}, clear=True),
|
|
||||||
patch("devx.molecule.lease.time.time", return_value=50.0),
|
|
||||||
):
|
|
||||||
result = runner.invoke(main)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert f"{LEASE_UNTIL_ENV}={50 + DEFAULT_TTL_SECONDS}" in result.output
|
|
||||||
assert f"{OWNER_ENV}=run-9-tests" in result.output
|
|
||||||
assert "GITHUB_ENV" not in result.output
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartDockerIntegration:
|
|
||||||
"""start_docker.main() must emit lease env on success (REQ-2)."""
|
|
||||||
|
|
||||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
|
||||||
def test_emits_lease_on_success(self, _mock: MagicMock) -> None:
|
|
||||||
from devx.molecule.start_docker import main as start_docker_main
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
m = mock_open()
|
|
||||||
with (
|
|
||||||
patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env", "GITHUB_RUN_ID": "7"}, clear=True),
|
|
||||||
patch("builtins.open", m),
|
|
||||||
):
|
|
||||||
result = runner.invoke(start_docker_main, ["--timeout", "1"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Exported lease vars to GITHUB_ENV" in result.output
|
|
||||||
assert "owner=run-7-job" in result.output
|
|
||||||
assert "until=" in result.output
|
|
||||||
Reference in New Issue
Block a user