Compare commits

..
12 Commits
Author SHA1 Message Date
gitea-actions-bot 6d3622465e chore: update badge URLs to commit 81d1d948 [skip ci] 2026-09-21 19:39:47 +00:00
emil 8633980062 DEVX-176: ci: docs fast-path, notify-failure scoping, post-merge queue
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 55s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-09-21 19:38:37 +00:00
gitea-actions-bot 693e7962da chore: update badge URLs to commit 22bda97e [skip ci] 2026-09-21 19:37:21 +00:00
devx-ci-bot 9408111088 release: v0.55.3 [skip ci] 2026-09-21 19:36:44 +00:00
emil 349057bf5d DEVX-175: fix: honor repo bandit config in badge quality check
Post-merge / release-and-maintain (push) Successful in 1m5s
Post-merge / detect-and-configure (push) Successful in 13s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-09-21 19:36:00 +00:00
gitea-actions-bot 3b2a278f87 chore: update badge URLs to commit b5c21b71 [skip ci] 2026-09-19 20:57:01 +00:00
devx-ci-bot 127f781076 release: v0.55.2 [skip ci] 2026-09-19 20:56:24 +00:00
kireto d41972093b DEVX-174: fix(ci): supply git identity for dep-PR manifest commits
Post-merge / release-and-maintain (push) Successful in 1m4s
Post-merge / detect-and-configure (push) Successful in 9s
2026-09-19 20:55:42 +00:00
gitea-actions-bot 5e0befe777 chore: update badge URLs to commit ee346d97 [skip ci] 2026-09-19 20:20:40 +00:00
devx-ci-bot fcc171183c release: v0.55.1 [skip ci] 2026-09-19 20:20:01 +00:00
kireto 9d0e4cf429 DEVX-173: fix(ci): resolve dep-PR container digest via registry v2 API
Post-merge / detect-and-configure (push) Successful in 9s
Post-merge / release-and-maintain (push) Successful in 1m7s
2026-09-19 20:19:22 +00:00
gitea-actions-bot 203d6971b3 chore: update badge URLs to commit 954c28d4 [skip ci] 2026-09-19 19:50:47 +00:00
16 changed files with 403 additions and 80 deletions
+25 -13
View File
@@ -33,21 +33,40 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# Implements: REQ-1 (DEVX-176) — docs-only changes skip the heavy
# quality steps. Detection needs only git, so it runs before setup.
- name: Detect docs-only change
id: docs-only
if: github.event_name == 'pull_request'
run: |
HEAD="${{ github.event.pull_request.head.sha || github.sha }}"
DOCS_ONLY=true
while IFS= read -r f; do
case "$f" in
docs/*|*.md|.devin/*) ;;
*) DOCS_ONLY=false; break;;
esac
done < <(git diff --name-only "origin/master...$HEAD")
echo "docs-only=$DOCS_ONLY" >> "$GITHUB_OUTPUT"
echo "docs-only=$DOCS_ONLY"
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
# --- quality steps --- # --- quality steps ---
- name: Lint all - name: Lint all
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
make lint-all make lint-all
- name: Unit tests with 100% coverage - name: Unit tests with 100% coverage
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
make pytest-cov make pytest-cov
- name: Check unit test speed - name: Check unit test speed
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5 python3 -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5
@@ -60,10 +79,12 @@ jobs:
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
make devx-docs-check make devx-docs-check
- name: Translation completeness check - name: Translation completeness check
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations python3 -m devx.ci.check_translations
- name: Dependency security scan - name: Dependency security scan
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit) # Install pip in venv if missing (needed by pip-audit)
@@ -71,6 +92,7 @@ jobs:
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \ PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation - name: Workflow dry-run validation
if: steps.docs-only.outputs.docs-only != 'true'
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -137,19 +159,9 @@ jobs:
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run python3 -m devx.ci.release --dry-run
- name: Notify on failure # Implements: REQ-2 (DEVX-176) — no failure-issue step in PR CI;
if: failure() # auto-created issues are for deploy-pipeline failures only
env: # (post-merge keeps its notification).
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "ci/validate" \
--commit "${{ github.sha }}" \
--auto-login
auto-merge: auto-merge:
# Auto-merge runs after validate passes. It reads the task ID # Auto-merge runs after validate passes. It reads the task ID
+3 -1
View File
@@ -24,7 +24,9 @@ on:
concurrency: concurrency:
group: post-merge-${{ github.ref }} group: post-merge-${{ github.ref }}
cancel-in-progress: true # Implements: REQ-3 (DEVX-176) — queue instead of killing an in-flight
# release/publish; a cancelled release can leave tag-without-publish.
cancel-in-progress: false
env: env:
PIP_BREAK_SYSTEM_PACKAGES: "1" PIP_BREAK_SYSTEM_PACKAGES: "1"
+18
View File
@@ -2,6 +2,24 @@
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.55.3] - 2026-09-21
### Bug Fixes
- Honor repo bandit config in badge quality check
## [0.55.2] - 2026-09-19
### Bug Fixes
- *(ci)* Supply git identity for dep-PR manifest commits
## [0.55.1] - 2026-09-19
### Bug Fixes
- *(ci)* Resolve dep-PR container digest via registry v2 API
## [0.55.0] - 2026-09-19 ## [0.55.0] - 2026-09-19
### Features ### Features
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/python.svg)](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.55.0", "devx>=0.55.3",
] ]
[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.55.0"`) or use a version constraint > `dependencies` (for example, `"devx==0.55.3"`) or use a version constraint
> (for example, `"devx>=0.55.0,<0.56"`). > (for example, `"devx>=0.55.3,<0.56"`).
### Optional extras ### Optional extras
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9eabfdd80f0a4232448178062a4dc63af23c84d4/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/81d1d94822d877773f6bbdc8bb6b941cf044fb4c/python.svg)](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.55.0", "devx>=0.55.3",
] ]
[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.55.0"` or `"devx>=0.55.0,<0.56"`. Pin a specific version if needed: `"devx==0.55.3"` or `"devx>=0.55.3,<0.56"`.
### Optional extras ### Optional extras
+49
View File
@@ -0,0 +1,49 @@
# DEVX-173: Resolve container digest via registry v2 API
## Problem
`resolve_container_digest` returns the sha256 of the `manifest.json`
blob listed by the Gitea packages API (`/packages/<owner>/container/
<name>/<tag>/files`). That blob digest is NOT the OCI manifest digest —
`docker pull repo@sha256:<blob>` fails with "not found." Production
deploy run 6251 died pulling `sso-bridge@sha256:5ca9...` which the dep
PR had recorded in `deploy/sso-bridge-release.json`. The registry serves
0.4.1 as `sha256:c0212ed1...` — different digest entirely.
## Approach
REQ-1: Keep the packages-API call as the existence check (it has the
404-retry semantics needed for the publish race) but resolve the
pullable digest via the registry v2 API: `GET /v2/token` with
`scope=repository:<owner>/<name>:pull` (basic-auth with the Gitea
token), then `GET /v2/<owner>/<name>/manifests/<tag>` with OCI/Docker
manifest Accept headers and read `Docker-Content-Digest`. Registry base
URL is derived from `GITEA_API_URL` (strip `/api/v1`).
REQ-2: If the v2 digest lookup fails (non-2xx, missing header), fail
closed with a ClickException — never record a blob sha256 as a pullable
digest.
## Test Plan
- Mocked v2 token + manifest endpoints return digest; recorded value is
the `Docker-Content-Digest` header.
- 404 retry semantics on the packages-API existence check unchanged.
- Missing digest header / non-2xx manifest response → ClickException.
- Unit tests cover token request scope and Accept headers.
## Deploy Plan
devx release tag; the sso-bridge dep-PR pin bump follows in its own PR.
The wrong digest already recorded in infra's manifest is corrected by
re-running the dep-PR with the fixed version.
## Rollback Plan
Revert; dep-PR records the (unpullable) blob digest again — deploys must
then use tag pulls until a corrected manifest lands.
## Acceptance Criteria
- [x] REQ-1: Digest resolved from `Docker-Content-Digest` via v2 API.
- [x] REQ-2: Lookup failures fail closed; no blob-sha256 fallback.
+31
View File
@@ -0,0 +1,31 @@
# DEVX-174: Set git identity in dep-PR target clone
## Problem
`create_dependency_pr` commits the manifest bump in a fresh clone of the
target repo. The clone has no `user.name`/`user.email`, so `git commit`
exits 128 ("Please tell me who you are") — observed in sso-bridge
post-merge run 6284 after the digest and manifest bump both succeeded.
## Approach
REQ-1: Pass a CI bot identity inline (`git -c user.name=... -c
user.email=... commit`) so the commit works in any environment without
mutating global git config.
## Test Plan
- Unit test asserting the commit invocation carries `-c user.name` /
`-c user.email` arguments.
## Deploy Plan
devx release; sso-bridge picks it up via its devx pin on next bump.
## Rollback Plan
Revert; dep-PR commit fails again in containers without git identity.
## Acceptance Criteria
- [x] REQ-1: dep-PR commit supplies explicit identity flags.
+40
View File
@@ -0,0 +1,40 @@
# DEVX-175: Badge generation must honor repo bandit configuration
## Problem
`collect_quality()` in `generate_badges.py` runs `bandit -r src/` with no
config file. Repos that declare a `[tool.bandit]` section in
`pyproject.toml` (skip lists aligned with infrastructure patterns — for
example sso-bridge skips B404/B603/B501) lint clean via `make lint-all`, but the
quality badge still reports `bandit: FAIL` — a false-FAIL badge that
contradicts the actual lint gate.
## Approach
REQ-1: When `pyproject.toml` exists in the target repo and contains a
`[tool.bandit]` section, pass `-c pyproject.toml` to the bandit invocation
in `collect_quality()`.
REQ-2: Repos without `[tool.bandit]` keep the current invocation unchanged.
## Test Plan
- Unit test: repo fixture with `[tool.bandit]` → command includes
`-c pyproject.toml`; without the section → plain invocation.
- Manual: `python -m devx.tools.generate_badges` in sso-bridge reports
`bandit: pass`.
## Deploy Plan
devx release → repos pick it up on next devx pin bump; no infra changes.
## Rollback Plan
Revert the commit; badges revert to unconfigured bandit runs.
## Acceptance Criteria
- [x] REQ-1 implemented with `# Implements: REQ-1` comment
- [x] REQ-2 preserved (no config → unchanged command)
- [x] Unit tests cover both branches
- [x] `make lint-all` + `make pytest-cov` pass in devx
+43
View File
@@ -0,0 +1,43 @@
# DEVX-176: CI hygiene — docs fast-path, failure-notify scoping, post-merge cancel
## Problem
devx CI has the same inefficiencies fixed in infra (OBL-INFRA-613/615/616):
docs-only PRs run the full quality suite (~10 min), CI failures auto-create
issues (noise — the user decided issues are for deploy failures only), and
post-merge `cancel-in-progress: true` can kill a release mid-publish.
## Approach
REQ-1: Docs-only PRs skip heavy validate steps (lint-all, unit tests,
test-speed, translation check, security scan, workflow dry-run). Docs gate,
spec validation, PR size, and auto-merge preconditions still run.
Restricted to pull_request events.
REQ-2: Remove the failure-issue step from `ci.yml` validate job.
Post-merge keeps failure notification (release/publish failures are
deploy-pipeline events).
REQ-3: post-merge `cancel-in-progress: false` — a new push queues instead
of killing an in-flight release/publish.
## Test Plan
- `make workflow-lint` passes.
- Docs-only PR: quality steps skipped, docs gate + spec + size + merge run.
- Non-docs PR: unchanged behavior.
## Deploy Plan
Workflow-only change; takes effect on merge. No release needed.
## Rollback Plan
Revert the commit.
## Acceptance Criteria
- [x] REQ-1 implemented — early docs-only step + step-level `if` gates
- [x] REQ-2 implemented — notify step removed from ci.yml only
- [x] REQ-3 implemented — post-merge concurrency flipped
- [x] `make workflow-lint` passes
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.55.0", "devx>=0.55.3",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.55.0", "devx>=0.55.3",
] ]
``` ```
+1 -1
View File
@@ -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.55.0" __version__ = "0.55.3"
+69 -17
View File
@@ -96,18 +96,25 @@ def update_pinned_version(file_path: str, package: str, old_version: str, new_ve
return changed return changed
_MANIFEST_ACCEPT = (
"application/vnd.oci.image.index.v1+json, "
"application/vnd.docker.distribution.manifest.list.v2+json, "
"application/vnd.oci.image.manifest.v1+json, "
"application/vnd.docker.distribution.manifest.v2+json"
)
# Implements: REQ-1 — existence check via packages API (keeps the 404-retry
# semantics for the publish race); the pullable digest is then resolved via
# the registry v2 API's Docker-Content-Digest header.
def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str, timeout_s: int = 0) -> str: def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str, timeout_s: int = 0) -> str:
"""Resolve the OCI digest for a container image tag via the packages API. """Verify the container tag exists, then resolve its pullable OCI digest.
Implements REQ-1: dependency PRs must only be opened after the producer The packages API proves the tag was published (and 404s while the
artifact exists this raises ClickException when the tag is missing or producer's image-build workflow races us — ``timeout_s`` retries every
the registry call fails, so the PR is never opened against an artifact 15s). The packages-API ``manifest.json`` blob sha256 is NOT pullable
that has not been published. The sha256 of the stored ``manifest.json`` via ``repo@sha256:...``, so the digest comes from the registry v2
blob is the manifest content digest (what ``docker pull`` reports). ``Docker-Content-Digest`` header instead.
Implements REQ-2: ``timeout_s`` > 0 retries the lookup every 15s until
the deadline the dep-PR step races the producer's image-build
workflow, which pushes the tag concurrently.
""" """
url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files" url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files"
headers = {"Authorization": f"token {token}"} headers = {"Authorization": f"token {token}"}
@@ -150,17 +157,47 @@ def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, toke
) )
) from e ) from e
break break
for f in resp.json(): return _resolve_registry_digest(api_url, owner, name, tag, token)
if f.get("name") == "manifest.json" and f.get("sha256"):
return f"sha256:{f['sha256']}"
# Implements: REQ-2 — v2 digest resolution fails closed: non-2xx, missing
# token, or absent Docker-Content-Digest all raise; a blob sha256 is never
# recorded as a pullable digest.
def _resolve_registry_digest(api_url: str, owner: str, name: str, tag: str, token: str) -> str:
registry = api_url.removesuffix("/api/v1").removesuffix("/")
repo = f"{owner}/{name}"
try:
tok_resp = requests.get( # nosec B310
f"{registry}/v2/token",
params={"service": "container_registry", "scope": f"repository:{repo}:pull"},
auth=("ci", token),
timeout=30,
)
tok_resp.raise_for_status()
bearer = tok_resp.json().get("token", "")
man_resp = requests.get( # nosec B310
f"{registry}/v2/{repo}/manifests/{tag}",
headers={"Authorization": f"Bearer {bearer}", "Accept": _MANIFEST_ACCEPT},
timeout=30,
)
man_resp.raise_for_status()
except requests.RequestException as e:
status = getattr(getattr(e, "response", None), "status_code", "?")
raise click.ClickException( raise click.ClickException(
_( _(
"Registry returned no manifest blob for {owner}/{name}:{tag}.", "Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}",
owner=owner, repo=repo,
name=name,
tag=tag, tag=tag,
status=status,
error=e,
) )
) from e
digest = man_resp.headers.get("Docker-Content-Digest", "")
if not digest:
raise click.ClickException(
_("Registry returned no Docker-Content-Digest for {repo}:{tag}.", repo=repo, tag=tag)
) )
return digest
def update_manifest(file_path: str, section: str, fields: dict[str, str]) -> bool: def update_manifest(file_path: str, section: str, fields: dict[str, str]) -> bool:
@@ -468,7 +505,22 @@ def cli(
add_files.append(spec_rel) add_files.append(spec_rel)
subprocess.run(["git", "add", *add_files], check=True, cwd=workdir) # nosec B603 B607 subprocess.run(["git", "add", *add_files], check=True, cwd=workdir) # nosec B603 B607
commit_msg = f"deps: bump {package} from {old_version} to {new_version}" commit_msg = f"deps: bump {package} from {old_version} to {new_version}"
subprocess.run(["git", "commit", "-m", commit_msg], check=True, cwd=workdir) # nosec B603 B607 # Implements: REQ-1 — the fresh clone has no git identity in CI
# containers; supply it inline (same bot identity as push_badges).
subprocess.run( # nosec B603 B607
[
"git",
"-c",
"user.name=gitea-actions-bot",
"-c",
"user.email=actions@oblachno.fyi",
"commit",
"-m",
commit_msg,
],
check=True,
cwd=workdir,
)
subprocess.run( # nosec B603 B607 subprocess.run( # nosec B603 B607
["git", "-c", auth_cfg, "push", "origin", branch_name], ["git", "-c", auth_cfg, "push", "origin", branch_name],
check=True, check=True,
+8 -1
View File
@@ -346,11 +346,18 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
results: list[bool] = [] results: list[bool] = []
tool_names: list[str] = [] tool_names: list[str] = []
# Implements: REQ-1 (DEVX-175) — honor the repo's [tool.bandit] config so
# the badge matches the project's actual lint gate (skip lists etc.).
bandit_cmd = [sys.executable, "-m", "bandit", "-r", "src/"]
pyproject = repo_root / "pyproject.toml"
if pyproject.exists() and "[tool.bandit]" in pyproject.read_text():
bandit_cmd += ["-c", "pyproject.toml"]
for cmd, name in [ for cmd, name in [
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"), ([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"), ([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
([sys.executable, "-m", "pyright"], "pyright"), ([sys.executable, "-m", "pyright"], "pyright"),
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"), (bandit_cmd, "bandit"),
]: ]:
rc, _stdout, stderr = run_command(cmd, cwd=repo_root) rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
if rc == 0: if rc == 0:
+15 -7
View File
@@ -2967,13 +2967,21 @@
"ru": "Ошибка поиска в реестре для {owner}/{name}:{tag}: {error}", "ru": "Ошибка поиска в реестре для {owner}/{name}:{tag}: {error}",
"zh": "注册表查询 {owner}/{name}:{tag} 失败:{error}" "zh": "注册表查询 {owner}/{name}:{tag} 失败:{error}"
}, },
"Registry returned no manifest blob for {owner}/{name}:{tag}.": { "Registry returned no Docker-Content-Digest for {repo}:{tag}.": {
"bg": "Регистърът не върна manifest blob за {owner}/{name}:{tag}.", "bg": "Регистърът не върна Docker-Content-Digest за {repo}:{tag}.",
"de": "Registry hat keinen Manifest-Blob für {owner}/{name}:{tag} zurückgegeben.", "de": "Registry hat keinen Docker-Content-Digest für {repo}:{tag} zurückgegeben.",
"en": "Registry returned no manifest blob for {owner}/{name}:{tag}.", "en": "Registry returned no Docker-Content-Digest for {repo}:{tag}.",
"pl": "Rejestr nie zwrócił blobu manifestu dla {owner}/{name}:{tag}.", "pl": "Rejestr nie zwrócił Docker-Content-Digest dla {repo}:{tag}.",
"ru": "Реестр не вернул blob манифеста для {owner}/{name}:{tag}.", "ru": "Реестр не вернул Docker-Content-Digest для {repo}:{tag}.",
"zh": "注册表未返回 {owner}/{name}:{tag} 的清单 blob。" "zh": "注册表未返回 {repo}:{tag} 的 Docker-Content-Digest。"
},
"Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}": {
"bg": "Неуспешна заявка за дайджест към регистър v2 за {repo}:{tag} (HTTP {status}): {error}",
"de": "Registry-v2-Digest-Abfrage für {repo}:{tag} fehlgeschlagen (HTTP {status}): {error}",
"en": "Registry v2 digest lookup failed for {repo}:{tag} (HTTP {status}): {error}",
"pl": "Zapytanie o skrót do rejestru v2 dla {repo}:{tag} nie powiodło się (HTTP {status}): {error}",
"ru": "Ошибка запроса дайджеста к реестру v2 для {repo}:{tag} (HTTP {status}): {error}",
"zh": "注册表 v2 摘要查询 {repo}:{tag} 失败 (HTTP {status}){error}"
}, },
"Regular merge commit — running all post-merge jobs.": { "Regular merge commit — running all post-merge jobs.": {
"bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.", "bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.",
+61 -17
View File
@@ -200,23 +200,39 @@ class TestCreateVikunjaTask:
assert mock_client.create_task.call_args.args[0] == 3 assert mock_client.create_task.call_args.args[0] == 3
class TestResolveContainerDigest: def _v2_mocks(digest: str = "sha256:deadbeef") -> list[MagicMock]:
"""REQ-1: pre-PR artifact verification via the packages API.""" """Token + manifest responses for the registry v2 digest lookup."""
tok = MagicMock()
tok.raise_for_status = MagicMock()
tok.json.return_value = {"token": "bearer-tok"}
man = MagicMock()
man.raise_for_status = MagicMock()
man.headers = {"Docker-Content-Digest": digest}
return [tok, man]
def test_returns_digest_from_manifest_blob(self) -> None:
class TestResolveContainerDigest:
"""REQ-1: existence check via packages API; digest via registry v2."""
def test_returns_digest_from_registry_v2(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock() files = MagicMock()
mock_resp.raise_for_status = MagicMock() files.raise_for_status = MagicMock()
mock_resp.json.return_value = [ files.json.return_value = [{"name": "manifest.json", "sha256": "blob-not-pullable"}]
{"name": "sha256_layer", "sha256": "abc"}, with patch(
{"name": "manifest.json", "sha256": "deadbeef"}, "devx.ci.create_dependency_pr.requests.get",
] side_effect=[files, *_v2_mocks()],
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp): ) as mock_get:
digest = resolve_container_digest( digest = resolve_container_digest(
"https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok" "https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok"
) )
assert digest == "sha256:deadbeef" assert digest == "sha256:deadbeef"
man_call = mock_get.call_args_list[2]
assert "manifests/0.9.1" in man_call.args[0]
assert "oci.image.index" in man_call.kwargs["headers"]["Accept"]
tok_call = mock_get.call_args_list[1]
assert tok_call.kwargs["params"]["scope"] == "repository:oblachno/sso-bridge:pull"
def test_raises_when_version_missing(self) -> None: def test_raises_when_version_missing(self) -> None:
import requests import requests
@@ -231,14 +247,38 @@ class TestResolveContainerDigest:
with pytest.raises(click.ClickException, match="unpublished artifact"): with pytest.raises(click.ClickException, match="unpublished artifact"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "9.9.9", "tok") resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "9.9.9", "tok")
def test_raises_when_no_manifest_blob(self) -> None: def test_raises_when_v2_digest_missing(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock() files = MagicMock()
mock_resp.raise_for_status = MagicMock() files.raise_for_status = MagicMock()
mock_resp.json.return_value = [{"name": "sha256_layer", "sha256": "abc"}] files.json.return_value = []
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp): tok, man = _v2_mocks(digest="")
with pytest.raises(click.ClickException, match="no manifest blob"): with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=[files, tok, man],
):
with pytest.raises(click.ClickException, match="no Docker-Content-Digest"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
def test_raises_when_v2_manifest_request_fails(self) -> None:
import requests
from devx.ci.create_dependency_pr import resolve_container_digest
files = MagicMock()
files.raise_for_status = MagicMock()
files.json.return_value = []
tok, _ = _v2_mocks()
err = requests.HTTPError("500")
err.response = MagicMock(status_code=500)
man = MagicMock()
man.raise_for_status.side_effect = err
with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=[files, tok, man],
):
with pytest.raises(click.ClickException, match="v2 digest lookup failed"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok") resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
def test_raises_on_connection_error(self) -> None: def test_raises_on_connection_error(self) -> None:
@@ -621,6 +661,10 @@ class TestBranchCreation:
# PR title carries the task ID # PR title carries the task ID
assert client.create_pr.call_args.kwargs["title"] == "OBL-INFRA-581: Bump sso_bridge to 0.9.1" assert client.create_pr.call_args.kwargs["title"] == "OBL-INFRA-581: Bump sso_bridge to 0.9.1"
mock_task.assert_called_once() mock_task.assert_called_once()
# Commit supplies an explicit identity (fresh clones have none)
commit = next(c for c in subprocess.run.call_args_list if "commit" in c.args[0])
assert "user.name=gitea-actions-bot" in commit.args[0]
assert "user.email=actions@oblachno.fyi" in commit.args[0]
def test_retries_404_until_published(self) -> None: def test_retries_404_until_published(self) -> None:
import requests import requests
@@ -635,7 +679,7 @@ class TestBranchCreation:
ok.raise_for_status = MagicMock() ok.raise_for_status = MagicMock()
ok.json.return_value = [{"name": "manifest.json", "sha256": "cafe"}] ok.json.return_value = [{"name": "manifest.json", "sha256": "cafe"}]
with ( with (
patch("devx.ci.create_dependency_pr.requests.get", side_effect=[fail, ok]), patch("devx.ci.create_dependency_pr.requests.get", side_effect=[fail, ok, *_v2_mocks("sha256:cafe")]),
patch("devx.ci.create_dependency_pr.time.sleep"), patch("devx.ci.create_dependency_pr.time.sleep"),
): ):
digest = resolve_container_digest( digest = resolve_container_digest(
+17
View File
@@ -365,6 +365,23 @@ class TestCollectQuality:
badge = collect_quality(tmp_path) badge = collect_quality(tmp_path)
assert badge["message"] == "A" assert badge["message"] == "A"
@patch("devx.tools.generate_badges.run_command")
def test_bandit_uses_repo_config_when_present(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text("[tool.bandit]\nskips = ['B501']\n")
mock_run.return_value = (0, "", "")
collect_quality(tmp_path)
bandit_call = [c for c in mock_run.call_args_list if "bandit" in c.args[0]][0]
assert "-c" in bandit_call.args[0]
assert "pyproject.toml" in bandit_call.args[0]
@patch("devx.tools.generate_badges.run_command")
def test_bandit_plain_invocation_without_repo_config(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n")
mock_run.return_value = (0, "", "")
collect_quality(tmp_path)
bandit_call = [c for c in mock_run.call_args_list if "bandit" in c.args[0]][0]
assert "-c" not in bandit_call.args[0]
class TestGenerateBadges: class TestGenerateBadges:
@patch("devx.tools.generate_badges.collect_quality") @patch("devx.tools.generate_badges.collect_quality")