Compare commits

..
1 Commits
Author SHA1 Message Date
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 176cb89189 refactor: remove cross-repo contract tests from devx
CI / validate (pull_request) Successful in 1m16s
CI / auto-merge (pull_request) Failing after 16s
devx tests were validating infra, grm, sso-bridge, and Mattermost OIDC
workflow/skill files. This is an architecture violation — devx must not
be aware of other repos. Those repos consume devx, not the other way
round.

Tests now only validate devx's own workflows and skills.

Closes DEVX-160

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-26 09:50:10 +02:00
26 changed files with 59 additions and 872 deletions
-135
View File
@@ -1,135 +0,0 @@
# dependency-graph
Map of the oblachno ecosystem. Knows which repo produces what, which
repos depend on which, and the correct order for cross-repo changes.
## When to Invoke
Invoke this skill when:
- Changes span multiple repos
- A change in one repo requires version bumps in downstream repos
- Deploying infrastructure that depends on published packages/images
- Verifying the ecosystem is in a consistent state before deployment
- Determining which repos to update and in what order
## Prerequisites
- All repos cloned under `/home/emo/dev/ideas/oblachno/`
- `.env` with `DEVELOPER_GITEA_API_TOKEN` in each repo
## Ecosystem Map
```
devx (PyPI package)
/ | \
/ | \
grm sso-bridge infra
(PyPI) (PyPI+Docker) (deploys all)
| | |
v v v
infra bump infra bump staging
(auto PR) (auto PR) production
|
mattermost-oidc (Docker image)
(infra pulls :latest at deploy)
```
## Repositories
| Repo | Produces | Consumers | Release Trigger |
|------|----------|-----------|-----------------|
| `devx` | PyPI package `devx` | grm, sso-bridge, infra | User-facing changes to `src/devx/**` |
| `grm` | PyPI package `grm` | infra | User-facing changes to `src/grm/**` or `ansible/**` |
| `sso-bridge` | PyPI package `sso_bridge` + Docker image | infra | User-facing changes to `src/sso_bridge/**` or `ansible/**` |
| `infra` | Staging/production deployment | (end users) | User-facing changes + nightly gate |
| `mattermost-oidc` | Docker image `mattermost-oidc` | infra (pulls at deploy) | `Dockerfile` or `build.yml` changes |
## Dependency Chain
### devx → all repos
devx publishes to the Gitea PyPI registry. grm, sso-bridge, and infra
pin devx in `pyproject.toml`:
```toml
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@vX.Y.Z"
```
When devx publishes a new version:
1. grm, sso-bridge, and infra must bump their pinned devx version
2. This is currently manual — no auto-dependency-PR from devx
3. Each repo must `make setup` to pick up the new version
### grm → infra
grm publishes to PyPI. Its post-merge workflow auto-creates an infra
dependency PR via `devx.ci.create_dependency_pr --repo oblachno/infra
--package grm`. The PR bumps the pinned grm version in infra's
`pyproject.toml`.
### sso-bridge → infra
sso-bridge publishes to PyPI AND builds a Docker image. Its post-merge
workflow auto-creates an infra dependency PR via
`devx.ci.create_dependency_pr --repo oblachno/infra --package
sso_bridge`. The PR bumps the pinned sso_bridge version.
The Docker image is pulled by infra at deploy time (`sso-bridge:latest`).
### mattermost-oidc → infra
mattermost-oidc builds a Docker image tagged `:latest` and `:MM_VERSION`.
infra pulls `mattermost-oidc:latest` at deploy time. There is no
auto-dependency-PR — infra simply pulls the latest image.
### infra → staging/production
infra deploys to staging and production. The deployment:
1. Provisions VMs from golden images
2. Runs Ansible roles (including grm and sso-bridge roles)
3. Pulls Docker images (sso-bridge, mattermost-oidc)
4. Configures services
## Correct Order for Cross-Repo Changes
When a change spans multiple repos, follow this order:
1. **devx first** — if the change starts in devx, merge and publish devx
first. Wait for the PyPI publish job to complete.
2. **Bump devx in consumers** — in grm/sso-bridge/infra, bump the pinned
devx version, run `make setup`, verify tests pass, merge.
3. **grm/sso-bridge second** — merge and publish grm/sso-bridge. Wait
for the PyPI publish + Docker image build to complete.
4. **Auto-dependency-PRs** — grm/sso-bridge post-merge auto-creates infra
PRs to bump pinned versions. Wait for these PRs to appear.
5. **Merge infra dependency PRs** — review and merge the auto-created
infra PRs.
6. **infra last** — deploy to staging, validate, promote to production.
## State Verification Before Deployment
Before deploying infra, verify:
1. **devx version consistent** — all repos pin the same devx version
2. **grm published** — latest grm tag exists in PyPI
3. **sso-bridge published** — latest sso_bridge tag exists in PyPI
4. **sso-bridge image built** — latest sso-bridge Docker image exists
5. **mattermost-oidc image built** — latest mattermost-oidc image exists
6. **infra pins match published versions** — no stale pins
7. **Nightly gate green**`NIGHTLY_STATUS` is not `failed`
## Quick Check Commands
```bash
# Check latest devx version
curl -sS https://git.oblachno.oblachno.fyi/api/v1/repos/oblachno-oss/devx/releases/latest | python3 -c "import json,sys; print(json.load(sys.stdin).get('tag_name','?'))"
# Check pinned devx version in each repo
for repo in grm sso-bridge infra; do
echo -n "$repo: "; grep 'devx @' /home/emo/dev/ideas/oblachno/$repo/pyproject.toml | grep -oP 'v[\d.]+'
done
# Check latest sso-bridge image build
curl -sS -H "Authorization: token $DEVELOPER_GITEA_API_TOKEN" \
"https://git.oblachno.oblachno.fyi/api/v1/repos/oblachno/sso-bridge/actions/runs?per_page=5" \
| python3 -c "import json,sys; [print(r['id'],r['status'],r['conclusion']) for r in json.load(sys.stdin).get('workflow_runs',[]) if r.get('event')=='push']"
```
@@ -1,90 +0,0 @@
# deployment-coordination
How devx releases propagate to downstream repos. devx is the base
package — all other repos pin it. A devx release must complete before
consumers can bump.
## When to Invoke
Invoke this skill when:
- Changes to devx affect downstream repos (grm, sso-bridge, infra)
- Preparing a devx release that other repos depend on
- Verifying downstream repos have bumped to the latest devx version
- Coordinating a multi-repo change that starts in devx
## Prerequisites
- devx repo at `/home/emo/dev/ideas/oblachno/devx`
- `.env` with `DEVELOPER_GITEA_API_TOKEN`
- See `dependency-graph` skill for the full ecosystem map
## What devx Produces
devx publishes a Python package to the Gitea PyPI registry. Downstream
repos pin it:
```toml
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@vX.Y.Z"
```
## Release Flow
1. PR merged to master
2. Post-merge workflow runs `devx.ci.release` — classifies changes
3. If user-facing changes: git-cliff bumps version, creates tag, pushes
4. `devx.ci.publish` builds and publishes to Gitea PyPI registry
5. Downstream repos must bump their pinned devx version
## Downstream Consumers
| Repo | Pin location | Auto-bump? |
|------|-------------|------------|
| grm | `pyproject.toml` | No — manual |
| sso-bridge | `pyproject.toml` | No — manual |
| infra | `pyproject.toml` | No — manual |
devx does NOT auto-create dependency PRs in downstream repos. Bumping
is manual: create a PR in each downstream repo to update the pinned
version.
## Coordinating a devx Change
When a change to devx affects downstream repos:
1. **Merge devx PR** — wait for post-merge publish to complete
2. **Verify publish** — check the new tag exists:
```bash
curl -sS -H "Authorization: token $DEVELOPER_GITEA_API_TOKEN" \
https://git.oblachno.oblachno.fyi/api/v1/repos/oblachno-oss/devx/releases/latest \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('tag_name','?'))"
```
3. **Bump downstream repos** — for each repo (grm, sso-bridge, infra):
- Update `devx @ ...@vX.Y.Z` in `pyproject.toml`
- Run `make setup` to install the new version
- Run `make pytest-cov` to verify compatibility
- Create and merge a PR
4. **Verify infra deploys** — after infra bumps, verify staging deploy
picks up the new devx version
## State Verification
Before starting a devx change, verify current state:
```bash
# Current devx version
grep '__version__' /home/emo/dev/ideas/oblachno/devx/src/devx/__init__.py
# What each repo pins
for repo in grm sso-bridge infra; do
echo -n "$repo pins: "
grep 'devx @' /home/emo/dev/ideas/oblachno/$repo/pyproject.toml | grep -oP 'v[\d.]+'
done
```
If pins are inconsistent across repos, bump them to the latest published
version before starting new work.
## Common Mistakes
- Merging a devx PR and immediately merging downstream PRs without
waiting for the publish job to complete
- Forgetting to bump infra (it has the most complex deploy pipeline)
- Bumping only one downstream repo when the change affects all three
-142
View File
@@ -1,142 +0,0 @@
# skill-creation
How to create, validate, and maintain Devin skills. Skills must be
clear, succinct, and actionable — no AI slop.
## When to Invoke
Invoke this skill when:
- Creating a new skill
- Amending an existing skill
- Evaluating whether a skill is needed
- Reviewing a PR that adds or modifies skills
## Prerequisites
- Skill directory: `.devin/skills/<skill-name>/SKILL.md`
- Validator: `tests/test_skills.py` (infra) or reference to it
- Tests: `tests/unit/test_skills_validation.py` (infra)
## When to Create a Skill
Create a skill when:
- An agent struggles with a task repeatedly (branch hygiene, PR order)
- A workflow has non-obvious ordering constraints (deployment coordination)
- A task requires specific tool usage over raw commands (CI monitoring)
- Multiple agents need shared context (dependency graph)
Do NOT create a skill for:
- One-off tasks (use a spec instead)
- Tasks already covered by AGENTS.md
- Tasks that are obvious from the Makefile or README
- Tasks that change frequently (skills should be stable)
## Skill Structure
Every skill MUST have:
```markdown
# <skill-name>
One-line description of what the skill does.
## When to Invoke
2-4 bullet points describing when to use this skill.
## Prerequisites
What must exist before using the skill (venv, .env, tools).
## <Core Content>
The actual guidance. Keep it actionable.
## Verification (if applicable)
How to verify the skill's guidance works.
## Common Mistakes (if applicable)
What agents get wrong without this skill.
```
## Quality Standards
### Do
- **Be specific.** Reference exact make targets, file paths, commands.
- **Be concise.** Each section should be scannable in under 30 seconds.
- **Be actionable.** Every paragraph should tell the agent what to DO.
- **Use tables** for command reference, mappings, and comparisons.
- **Use code blocks** for commands the agent should run.
- **Link to other skills** when related (e.g., "See `dependency-graph` skill").
### Don't
- **No preamble.** Don't start with "This skill helps agents..." — just state what it does.
- **No filler.** Don't repeat information from AGENTS.md or other skills.
- **No vague advice.** "Be careful with branches" is useless. "Run `git branch --show-current` before every commit" is useful.
- **No AI slop.** Don't write "In this comprehensive guide, we will explore..." — just give the guidance.
- **No redundant sections.** If "Common Mistakes" would repeat "When to Invoke", skip it.
- **No marketing.** Don't describe the skill as "powerful" or "comprehensive".
## Scope Rules
- **One skill per concern.** Don't mix branch hygiene with CI monitoring.
- **Project-specific, not generic.** Skills reference this repo's make targets, file paths, and conventions — not abstract advice.
- **Shared skills must be identical across repos.** Use `SHARED_SKILLS` in the validator to enforce this.
- **Per-repo skills must reflect that repo's reality.** Don't copy infra-specific targets to sso-bridge.
## Automated Validation
Every skill must pass the validator (`tests/test_skills.py`). The validator checks:
1. **Structure** — H1 title, "When to Invoke" section, "Prerequisites" section
2. **Commands** — referenced `make <target>` commands exist in Makefile or devx.mak
3. **Paths** — referenced file paths exist in the repo
4. **Shared skills** — identical content across repos (SHA-256 comparison)
5. **No drift** — no references to nonexistent commands or files
Run the validator:
```bash
python3 tests/test_skills.py --repo infra --repo sso-bridge
```
## Effectiveness Evaluation
### Static Checks (automated, CI)
The validator runs in CI as part of `make pytest-cov`. A failing skill
test blocks the PR. This catches:
- Missing sections
- Invalid commands
- Broken file references
- Cross-repo drift
### Runtime Metrics (manual, periodic)
Track these signals to evaluate skill effectiveness:
- **Skill invocation frequency** — how often agents invoke the skill
- **Success rate when invoked** — did the skill prevent the mistake it targets?
- **Feedback issues** — agents create Gitea issues with `feedback` label when a skill is unclear or wrong
- **Mistake recurrence** — if agents still make the mistake the skill targets, the skill needs improvement
### Retrospective Review
Periodically (monthly or after major incidents) review skills:
1. List all skills and their last-modified dates
2. Check for feedback issues tagged `skill-improvement`
3. Verify referenced commands still exist (run validator)
4. Remove skills that are no longer relevant
5. Update skills where mistakes still recur
6. Document lessons in this skill's "Common Mistakes" section
## Creating a New Skill — Checklist
- [ ] Identify the repeated struggle or non-obvious workflow
- [ ] Check no existing skill covers it
- [ ] Write the skill following the structure above
- [ ] Run `python3 tests/test_skills.py` — must pass
- [ ] Run `make pytest-cov` — must pass with 100% coverage
- [ ] If shared across repos, copy identical content to each repo
- [ ] Add the skill to `SHARED_SKILLS` in the validator if shared
- [ ] Create PR, verify CI passes, merge
-3
View File
@@ -77,9 +77,6 @@ jobs:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
PYTHONPATH: src PYTHONPATH: src
# Serialize blob uploads to avoid Gitea registry race condition
# (BlobUploader.Append offset mismatch — see DEVX-162).
DOCKER_MAX_CONCURRENT_UPLOADS: "1"
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
+7 -20
View File
@@ -181,31 +181,18 @@ jobs:
- name: Post approval review - name: Post approval review
env: env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }} REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
# Post APPROVE review via Gitea API to satisfy branch protection. # Post APPROVE review via Gitea API to satisfy branch protection
# Try REVIEWER_GITEA_API_TOKEN first; fall back to CI_GITEA_API_TOKEN curl -s -X POST \
# (CI bot account) if the reviewer token is the same user as the PR "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
# creator (Gitea rejects self-approvals). -H "Authorization: token ${REVIEWER_GITEA_API_TOKEN}" \
for TOKEN in "${REVIEWER_GITEA_API_TOKEN}" "${CI_GITEA_API_TOKEN}"; do -H "Content-Type: application/json" \
[ -z "$TOKEN" ] && continue -d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate job)."}' \
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ || echo "::warning::Failed to post approval review (best-effort)."
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate job)."}')
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
echo "Approval posted successfully (HTTP $HTTP_CODE)."
break
fi
echo "::warning::Approval with token failed (HTTP $HTTP_CODE): ${BODY}"
done
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
-46
View File
@@ -2,52 +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.51.9] - 2026-08-26
### Bug Fixes
- Exclude docs/plans/* from PR size check
## [0.51.8] - 2026-08-26
### Bug Fixes
- Accept deps: as valid conventional commit type
## [0.51.7] - 2026-08-26
### Bug Fixes
- Increase HTTP 500 retry count to 5 with longer backoff and visible logging
## [0.51.6] - 2026-08-26
### Bug Fixes
- Use stderr=STDOUT to capture all docker push output in one stream
## [0.51.5] - 2026-08-26
### Bug Fixes
- Check stdout for HTTP 500 in _run_push (docker sends to stdout)
## [0.51.4] - 2026-08-26
### Bug Fixes
- Serialize registry uploads and retry on HTTP 500
### Refactor
- Remove cross-repo contract tests from devx
## [0.51.3] - 2026-08-26
### Bug Fixes
- Fall back to CI bot token for auto-merge approval
## [0.51.2] - 2026-08-26 ## [0.51.2] - 2026-08-26
### Bug Fixes ### Bug Fixes
+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/09be76620c0a26418c614fb26b7feec2a4f457d3/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/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.51.9", "devx>=0.51.2",
] ]
[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.51.9"`) or use a version constraint > `dependencies` (for example, `"devx==0.51.2"`) or use a version constraint
> (for example, `"devx>=0.51.9,<0.52"`). > (for example, `"devx>=0.51.2,<0.52"`).
### 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/09be76620c0a26418c614fb26b7feec2a4f457d3/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1a5ea7fe7f0c64db446534e218c8d6a499777d4c/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.51.9", "devx>=0.51.2",
] ]
[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.51.9"` or `"devx>=0.51.9,<0.52"`. Pin a specific version if needed: `"devx==0.51.2"` or `"devx>=0.51.2,<0.52"`.
### Optional extras ### Optional extras
-27
View File
@@ -1,27 +0,0 @@
# DEVX-161: Fix auto-merge self-approval: use CI bot token fallback
## Problem
The auto-merge workflow posts an APPROVE review using
`REVIEWER_GITEA_API_TOKEN`. When this token belongs to the same user
who created the PR, Gitea rejects the self-approval, causing the merge
to fail with HTTP 405 "Does not have enough approvals."
## Approach
Try `REVIEWER_GITEA_API_TOKEN` first; if it fails (self-approval
rejection), fall back to `CI_GITEA_API_TOKEN` (kireto — CI bot account).
REQ-1: Auto-merge posts approval with fallback to CI bot token
REQ-2: Approval step reports which token succeeded
## Test Plan
- Create a PR and observe auto-merge succeeds
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Auto-merge posts approval with fallback to CI bot token
- [x] REQ-2: Approval step reports which token succeeded
-41
View File
@@ -1,41 +0,0 @@
# DEVX-162: Fix registry push race condition: serialize uploads + retry on HTTP 500
## Problem
The Gitea container registry (v1.27.2) has a known race condition in
`BlobUploader.Append()` where concurrent blob uploads cause the file
offset and DB model to get out of sync, producing HTTP 500 "offset
mismatch between file and model" errors. This causes the build-images
workflow to fail intermittently when pushing runner images.
The `package_blob_upload` table accumulates stale entries from failed
uploads that worsen the problem over time.
## Approach
Two fixes in devx (a third fix — scheduled cleanup — is tracked
separately as OBL-INFRA-537):
1. Set `DOCKER_MAX_CONCURRENT_UPLOADS=1` in the build-images workflow
to serialize blob uploads and avoid the race condition.
2. Add HTTP 500 retry logic to `push_image` in `build_image.py`.
When a push fails with HTTP 500 (not "already exists"), retry up
to 3 times with exponential backoff (5s, 10s, 20s).
REQ-1: Build-images workflow sets DOCKER_MAX_CONCURRENT_UPLOADS=1
REQ-2: push_image retries on HTTP 500 with exponential backoff
REQ-3: All existing tests pass with 100% coverage
## Test Plan
- Unit tests for retry logic (mock subprocess)
- Manual: trigger build-images workflow and verify push succeeds
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Build-images workflow sets DOCKER_MAX_CONCURRENT_UPLOADS=1
- [x] REQ-2: push_image retries on HTTP 500 with exponential backoff
- [x] REQ-3: All existing tests pass with 100% coverage
-33
View File
@@ -1,33 +0,0 @@
# DEVX-163: Fix _run_push to check stdout for HTTP 500
## Problem
`_run_push` only checked `result.stderr` for HTTP 500, but docker push
sends the "received unexpected HTTP status: 500 Internal Server Error"
message to **stdout**, not stderr. This means the tenacity retry logic
added in DEVX-162 never triggered — the push failed immediately without
retrying.
## Approach
Check both `result.stdout` and `result.stderr` for the "500" status code.
Also update the "already exists" check in `push_image` to check both
streams, since docker may send that message to stdout as well.
REQ-1: _run_push checks both stdout and stderr for HTTP 500
REQ-2: push_image "already exists" check uses combined stdout+stderr
REQ-3: All existing tests pass with 100% coverage
## Test Plan
- Unit tests for stdout 500 detection
- Unit tests for stderr 500 detection
- Manual: trigger build-images workflow and verify retry works
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: _run_push checks both stdout and stderr for HTTP 500
- [x] REQ-2: push_image "already exists" check uses combined stdout+stderr
- [x] REQ-3: All existing tests pass with 100% coverage
-34
View File
@@ -1,34 +0,0 @@
# DEVX-164: Increase HTTP 500 retry count and backoff for docker push
## Problem
The HTTP 500 retry logic (DEVX-162, DEVX-163) works correctly — 3 retry
attempts are made. But all 3 attempts fail because the Gitea registry's
"offset mismatch" race condition needs more than ~15s to recover. The
current backoff is 5s-20s with 3 attempts (total ~15s of waiting).
## Approach
Increase retry count from 3 to 5 and backoff from 5-20s to 10-60s,
giving the registry up to ~2 minutes to recover. Add visible logging
between retry attempts so the CI logs show the retry happening.
REQ-1: Increase retry count from 3 to 5
REQ-2: Increase backoff from 5-20s to 10-60s exponential
REQ-3: Add visible logging between retry attempts (click.echo)
REQ-4: All tests pass with 100% coverage
## Test Plan
- Unit tests verify retry count and backoff parameters
- Unit tests verify logging output on retry
- Manual: trigger build-images workflow and verify retries visible in logs
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Increase retry count from 3 to 5
- [x] REQ-2: Increase backoff from 5-20s to 10-60s exponential
- [x] REQ-3: Add visible logging between retry attempts (click.echo)
- [x] REQ-4: All tests pass with 100% coverage
-31
View File
@@ -1,31 +0,0 @@
# DEVX-165: Accept deps: as valid conventional commit type
## Problem
The commit validator rejects `deps:` as a conventional commit type, causing
post-merge CI failures on grm and sso-bridge repos where automated dependency
bump PRs use `deps: bump devx...` as the commit message.
## Approach
REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
REQ-2: Update the allowed types list in the error message in
`src/devx/ci/validate_commit_msg.py`
REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
and `tests/unit/test_validate_commit_msg.py`
## Test Plan
- `make pytest-cov` passes with 100% coverage
- `make lint-all` passes
## Deploy Plan
- Merge to master → post-merge auto-publishes new devx version
- grm and sso-bridge bump devx version to pick up the fix
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
- [x] REQ-2: Update the allowed types list in the error message in
`src/devx/ci/validate_commit_msg.py`
- [x] REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
and `tests/unit/test_validate_commit_msg.py`
-27
View File
@@ -1,27 +0,0 @@
# DEVX-166: Exclude docs/plans/* from PR size check
## Problem
Planning docs in `docs/plans/` are legitimately large (700+ lines) but
fail the PR size check (max 500 lines). This blocks PRs that only add
planning documents.
## Approach
REQ-1: Add `docs/plans/*` to `DEFAULT_EXCLUDED_PATTERNS` in
`src/devx/ci/check_pr_size.py`
REQ-2: Add test coverage for the new exclusion pattern
## Test Plan
- `make pytest-cov` passes with 100% coverage
- `make lint-all` passes
## Deploy Plan
- Merge to master → post-merge auto-publishes new devx version
- Infra PR #1179 picks up the fix once devx is bumped
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Add `docs/plans/*` to `DEFAULT_EXCLUDED_PATTERNS` in
`src/devx/ci/check_pr_size.py`
- [x] REQ-2: Add test coverage for the new exclusion pattern
-64
View File
@@ -1,64 +0,0 @@
# DEVX-167: Add dependency-graph, deployment-coordination, and skill-creation skills
## Problem
Agents working across the oblachno ecosystem lack shared, persistent
context for three recurring pain points:
1. **Cross-repo dependency ordering** — agents frequently merge
downstream PRs before the upstream publish job completes, or forget
to bump infra. There is no single reference for which repo produces
what and in what order changes must propagate.
2. **devx release coordination** — devx is the base package pinned by
grm, sso-bridge, and infra. Agents repeatedly merge devx PRs and
immediately merge downstream bumps without waiting for the PyPI
publish job, or bump only one consumer when a change affects all
three.
3. **Skill quality drift** — skills are created ad hoc with inconsistent
structure, vague advice, and no automated validation reference. New
skills miss required sections, reference nonexistent make targets,
and drift across repos.
## Approach
Add three SKILL.md files under `.devin/skills/`:
REQ-1: `dependency-graph` — shared skill mapping the oblachno ecosystem
(repos, what each produces, consumers, release triggers, correct
cross-repo change order, state verification checklist)
REQ-2: `deployment-coordination` — devx-specific skill covering the
devx release flow, downstream consumers, manual bump procedure, and
common mistakes when coordinating a devx change
REQ-3: `skill-creation` — shared skill defining skill structure,
quality standards, scope rules, automated validation reference, and a
creation checklist
## Files Affected
- `.devin/skills/dependency-graph/SKILL.md` (new)
- `.devin/skills/deployment-coordination/SKILL.md` (new)
- `.devin/skills/skill-creation/SKILL.md` (new)
- `docs/specs/DEVX-167.md` (new)
## Test Plan
- Verify all three SKILL.md files follow the required structure (H1
title, When to Invoke, Prerequisites sections)
- Verify referenced make targets and file paths exist
- Run `make pytest-cov` — skill validation tests must pass
## Deploy Plan
- Merge to master; skills are consumed by agents immediately on next
invocation — no build or deploy step required
## Rollback Plan
- Revert the merge commit; remove the three skill directories
## Acceptance Criteria
- [x] REQ-1: dependency-graph skill exists with ecosystem map, repo
table, dependency chain, cross-repo change order, and state
verification checklist
- [x] REQ-2: deployment-coordination skill exists with devx release
flow, downstream consumer table, coordination steps, and common
mistakes
- [x] REQ-3: skill-creation skill exists with structure template,
quality standards, scope rules, validation reference, and
creation checklist
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.51.9", "devx>=0.51.2",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.51.9", "devx>=0.51.2",
] ]
``` ```
+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.51.9" __version__ = "0.51.2"
-1
View File
@@ -36,7 +36,6 @@ DEFAULT_EXCLUDED_PATTERNS = [
"CHANGELOG.md", "CHANGELOG.md",
"README.md", "README.md",
"docs/index.md", "docs/index.md",
"docs/plans/*",
"*.svg", "*.svg",
"uv.lock", "uv.lock",
"poetry.lock", "poetry.lock",
+1 -1
View File
@@ -118,7 +118,7 @@ def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> Non
" Expected: <type>: <description>\n" " Expected: <type>: <description>\n"
" Got: {subject}\n" " Got: {subject}\n"
" Allowed types: feat, fix, chore, docs, style, refactor,\n" " Allowed types: feat, fix, chore, docs, style, refactor,\n"
" perf, test, ci, build, deps, revert, BREAKING CHANGE", " perf, test, ci, build, revert, BREAKING CHANGE",
subject=subject, subject=subject,
) )
) )
+1 -1
View File
@@ -93,4 +93,4 @@ RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
RETRY_STATUS_CODES = {429, 500, 502, 503, 504} RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
# Conventional commit regex — used by validate_commit_msg.py # Conventional commit regex — used by validate_commit_msg.py
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|deps)(\(.+\))?: .+") CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
+10 -67
View File
@@ -50,7 +50,6 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import click import click
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token from devx.tokens import get_developer_token
@@ -255,36 +254,6 @@ def delete_remote_manifest(
return True return True
class PushHTTP500Error(Exception):
"""Raised when docker push fails with an HTTP 500 from the registry."""
def _run_push(cmd: list[str]) -> subprocess.CompletedProcess[str]:
"""Run a docker push command, raising PushHTTP500Error on registry 500.
The Gitea container registry (v1.27.x) has a race condition in
BlobUploader.Append that causes intermittent HTTP 500 "offset
mismatch" errors during concurrent blob uploads. Retrying the
push gives the registry time to recover.
Docker sends push progress/errors to both stdout and stderr depending
on the error type, so both streams are checked for the 500 status.
Uses stderr=STDOUT to merge both streams into stdout, ensuring all
output is captured in one place (docker push output behavior varies
depending on TTY detection).
"""
result = subprocess.run( # nosec B603
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if result.returncode != 0 and "500" in (result.stdout or ""):
raise PushHTTP500Error(result.stdout.strip())
return result
def push_image( def push_image(
spec: ImageSpec, spec: ImageSpec,
registry: str, registry: str,
@@ -301,9 +270,6 @@ def push_image(
with Gitea #31964 ("package version already exists") do we delete with Gitea #31964 ("package version already exists") do we delete
the old manifest and retry. This avoids losing the existing tag the old manifest and retry. This avoids losing the existing tag
when the push fails for unrelated reasons (e.g. HTTP 500). when the push fails for unrelated reasons (e.g. HTTP 500).
HTTP 500 errors from the Gitea registry race condition are retried
up to 3 times with exponential backoff (5s, 10s) via tenacity.
""" """
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags] full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
all_ok = True all_ok = True
@@ -313,41 +279,19 @@ def push_image(
click.echo(f"[dry-run] {' '.join(cmd)}") click.echo(f"[dry-run] {' '.join(cmd)}")
continue continue
click.echo(f"Pushing {ft}...") click.echo(f"Pushing {ft}...")
result = subprocess.run( # nosec B603
@retry( cmd,
stop=stop_after_attempt(5), capture_output=True,
wait=wait_exponential(multiplier=10, min=10, max=60), text=True,
retry=retry_if_exception_type(PushHTTP500Error), check=False,
before_sleep=lambda retry_state: click.echo(
_(
" HTTP 500 from registry, retrying in {wait:.0f}s (attempt {attempt}/5)...",
wait=retry_state.next_action.sleep if retry_state.next_action else 0,
attempt=retry_state.attempt_number + 1,
),
err=True,
),
reraise=True,
) )
def _attempt(_cmd: list[str] = cmd) -> subprocess.CompletedProcess[str]:
return _run_push(_cmd)
try:
result = _attempt()
except PushHTTP500Error as e:
click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=str(e)),
err=True,
)
all_ok = False
continue
if result.returncode == 0: if result.returncode == 0:
click.echo(f"Pushed {ft}") click.echo(f"Pushed {ft}")
continue continue
combined_output = (result.stdout or "").strip() stderr = result.stderr.strip()
# Gitea #31964: push fails because tag already exists. # Gitea #31964: push fails because tag already exists.
# Delete the old manifest and retry once. # Delete the old manifest and retry once.
if username and token and "already exists" in combined_output.lower(): if username and token and "already exists" in stderr.lower():
click.echo(" Tag exists (Gitea #31964), deleting old manifest and retrying...") click.echo(" Tag exists (Gitea #31964), deleting old manifest and retrying...")
delete_remote_manifest( delete_remote_manifest(
registry, registry,
@@ -360,17 +304,16 @@ def push_image(
click.echo(f" Retrying push {ft}...") click.echo(f" Retrying push {ft}...")
result = subprocess.run( # nosec B603 result = subprocess.run( # nosec B603
cmd, cmd,
stdout=subprocess.PIPE, capture_output=True,
stderr=subprocess.STDOUT,
text=True, text=True,
check=False, check=False,
) )
if result.returncode == 0: if result.returncode == 0:
click.echo(f"Pushed {ft} (after retry)") click.echo(f"Pushed {ft} (after retry)")
continue continue
combined_output = (result.stdout or "").strip() stderr = result.stderr.strip()
click.echo( click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=combined_output), _("Push failed for {tag}: {error}", tag=ft, error=stderr),
err=True, err=True,
) )
all_ok = False all_ok = False
+7 -15
View File
@@ -2999,13 +2999,13 @@
"PR number for label check": "PR number for label check", "PR number for label check": "PR number for label check",
"Repo (owner/name) for label check": "Repo (owner/name) for label check" "Repo (owner/name) for label check": "Repo (owner/name) for label check"
}, },
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, deps, revert, BREAKING CHANGE": { "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, deps, revert, BREAKING CHANGE", "bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, deps, revert, BREAKING CHANGE", "de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, deps, revert, BREAKING CHANGE", "en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"pl": "Ups! Wiadomość commit musi być w formacie conventional commit.\n Oczekiwano: <typ>: <opis>\n Otrzymano: {subject}\n Dozwolone typy: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, deps, revert, BREAKING CHANGE", "pl": "Ups! Wiadomość commit musi być w formacie conventional commit.\n Oczekiwano: <typ>: <opis>\n Otrzymano: {subject}\n Dozwolone typy: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, deps, revert, BREAKING CHANGE", "ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, deps, revert, BREAKING CHANGE", "zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"PR number for label check": "PR number for label check", "PR number for label check": "PR number for label check",
"Repo (owner/name) for label check": "Repo (owner/name) for label check" "Repo (owner/name) for label check": "Repo (owner/name) for label check"
}, },
@@ -5152,13 +5152,5 @@
"pl": "PR has 'refactoring' label — size check bypassed.", "pl": "PR has 'refactoring' label — size check bypassed.",
"ru": "PR has 'refactoring' label — size check bypassed.", "ru": "PR has 'refactoring' label — size check bypassed.",
"zh": "PR has 'refactoring' label — size check bypassed." "zh": "PR has 'refactoring' label — size check bypassed."
},
" HTTP 500 from registry, retrying in {wait:.0f}s (attempt {attempt}/5)...": {
"en": "HTTP 500 from registry, retrying in {wait:.0f}s (attempt {attempt}/5)...",
"bg": " HTTP 500 от регистъра, повторен опит след {wait:.0f}с (опит {attempt}/5)...",
"de": " HTTP 500 vom Registry, Wiederholung in {wait:.0f}s (Versuch {attempt}/5)...",
"pl": " HTTP 500 z rejestru, ponawianie za {wait:.0f}s (próba {attempt}/5)...",
"ru": " HTTP 500 от реестра, повтор через {wait:.0f}с (попытка {attempt}/5)...",
"zh": " 注册表返回 HTTP 500{wait:.0f}秒后重试(第{attempt}/5次尝试)..."
} }
} }
+13 -66
View File
@@ -13,7 +13,6 @@ from click.testing import CliRunner
import devx.tools.build_image as build_image import devx.tools.build_image as build_image
from devx.tools.build_image import ( from devx.tools.build_image import (
ImageSpec, ImageSpec,
PushHTTP500Error,
build_full_tag, build_full_tag,
delete_remote_manifest, delete_remote_manifest,
load_manifest, load_manifest,
@@ -198,7 +197,7 @@ class TestBuildImage:
class TestPushImage: class TestPushImage:
def test_success(self) -> None: def test_success(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
mock_result = MagicMock(returncode=0, stdout="") mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert push_image(spec, "git.example.com") is True assert push_image(spec, "git.example.com") is True
assert mock_run.call_count == 2 assert mock_run.call_count == 2
@@ -206,8 +205,8 @@ class TestPushImage:
def test_partial_failure(self) -> None: def test_partial_failure(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
results = [ results = [
MagicMock(returncode=0, stdout=""), MagicMock(returncode=0, stderr="", stdout=""),
MagicMock(returncode=1, stdout="push failed"), MagicMock(returncode=1, stderr="push failed", stdout=""),
] ]
with patch("devx.tools.build_image.subprocess.run", side_effect=results): with patch("devx.tools.build_image.subprocess.run", side_effect=results):
assert push_image(spec, "git.example.com") is False assert push_image(spec, "git.example.com") is False
@@ -221,7 +220,7 @@ class TestPushImage:
def test_no_delete_on_success_with_creds(self) -> None: def test_no_delete_on_success_with_creds(self) -> None:
"""Push-first: no delete needed when push succeeds.""" """Push-first: no delete needed when push succeeds."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=0, stdout="") mock_result = MagicMock(returncode=0, stderr="", stdout="")
with ( with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result), patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del, patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
@@ -231,7 +230,7 @@ class TestPushImage:
def test_no_delete_without_creds(self) -> None: def test_no_delete_without_creds(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=0, stdout="") mock_result = MagicMock(returncode=0, stderr="", stdout="")
with ( with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result), patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del, patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
@@ -243,8 +242,8 @@ class TestPushImage:
"""Gitea #31964: push fails with 'already exists', delete + retry.""" """Gitea #31964: push fails with 'already exists', delete + retry."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
results = [ results = [
MagicMock(returncode=1, stdout="package version already exists"), MagicMock(returncode=1, stderr="500 Internal Server Error: already exists", stdout=""),
MagicMock(returncode=0, stdout=""), MagicMock(returncode=0, stderr="", stdout=""),
] ]
with ( with (
patch("devx.tools.build_image.subprocess.run", side_effect=results), patch("devx.tools.build_image.subprocess.run", side_effect=results),
@@ -261,9 +260,11 @@ class TestPushImage:
) )
def test_no_delete_on_non_already_exists_failure(self) -> None: def test_no_delete_on_non_already_exists_failure(self) -> None:
"""Push fails for other reasons (non-500) — old manifest preserved.""" """Push fails for other reasons (HTTP 500) — old manifest preserved."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=1, stdout="denied: requested access to the resource is denied") mock_result = MagicMock(
returncode=1, stderr="received unexpected HTTP status: 500 Internal Server Error", stdout=""
)
with ( with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result), patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del, patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
@@ -275,8 +276,8 @@ class TestPushImage:
"""Gitea #31964 retry also fails — both pushes fail.""" """Gitea #31964 retry also fails — both pushes fail."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
results = [ results = [
MagicMock(returncode=1, stdout="package version already exists"), MagicMock(returncode=1, stderr="500 Internal Server Error: already exists", stdout=""),
MagicMock(returncode=1, stdout="push failed again"), MagicMock(returncode=1, stderr="push failed again", stdout=""),
] ]
with ( with (
patch("devx.tools.build_image.subprocess.run", side_effect=results), patch("devx.tools.build_image.subprocess.run", side_effect=results),
@@ -284,60 +285,6 @@ class TestPushImage:
): ):
assert push_image(spec, "git.example.com", username="user", token="tok") is False assert push_image(spec, "git.example.com", username="user", token="tok") is False
def test_http_500_retries_then_succeeds(self) -> None:
"""HTTP 500 from registry race condition — retry succeeds."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
results = [
MagicMock(returncode=1, stdout="received unexpected HTTP status: 500 Internal Server Error"),
MagicMock(returncode=0, stdout=""),
]
with (
patch("devx.tools.build_image.subprocess.run", side_effect=results),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
patch("time.sleep"),
):
assert push_image(spec, "git.example.com", username="user", token="tok") is True
mock_del.assert_not_called()
def test_http_500_retries_all_fail(self) -> None:
"""HTTP 500 retries exhausted — push fails, no delete attempted."""
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
mock_result = MagicMock(returncode=1, stdout="received unexpected HTTP status: 500 Internal Server Error")
with (
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
patch("time.sleep"),
):
assert push_image(spec, "git.example.com", username="user", token="tok") is False
mock_del.assert_not_called()
def test_run_push_raises_on_500(self) -> None:
"""_run_push raises PushHTTP500Error when stdout contains 500."""
from devx.tools.build_image import _run_push
mock_result = MagicMock(returncode=1, stdout="received unexpected HTTP status: 500 Internal Server Error")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
with pytest.raises(PushHTTP500Error, match="500"):
_run_push(["docker", "push", "img:latest"])
def test_run_push_no_raise_on_non_500(self) -> None:
"""_run_push returns result when stdout has no 500."""
from devx.tools.build_image import _run_push
mock_result = MagicMock(returncode=1, stdout="denied: access denied")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = _run_push(["docker", "push", "img:latest"])
assert result.returncode == 1
def test_run_push_no_raise_on_success(self) -> None:
"""_run_push returns result on success."""
from devx.tools.build_image import _run_push
mock_result = MagicMock(returncode=0, stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = _run_push(["docker", "push", "img:latest"])
assert result.returncode == 0
class TestDeleteRemoteManifest: class TestDeleteRemoteManifest:
def test_dry_run(self) -> None: def test_dry_run(self) -> None:
-6
View File
@@ -26,12 +26,6 @@ class TestIsExcluded:
def test_excludes_readme(self) -> None: def test_excludes_readme(self) -> None:
assert is_excluded("README.md", ["README.md"]) assert is_excluded("README.md", ["README.md"])
def test_excludes_plans_glob(self) -> None:
assert is_excluded("docs/plans/sso-bridge-full-extraction.md", ["docs/plans/*"])
def test_does_not_exclude_specs(self) -> None:
assert not is_excluded("docs/specs/DEVX-165.md", ["docs/plans/*"])
class TestCheckSize: class TestCheckSize:
def test_under_limits_passes(self) -> None: def test_under_limits_passes(self) -> None:
-1
View File
@@ -38,7 +38,6 @@ class TestConfigConstants:
def test_conventional_re(self) -> None: def test_conventional_re(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature") assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix(scope): bug fix") assert CONVENTIONAL_RE.match("fix(scope): bug fix")
assert CONVENTIONAL_RE.match("deps: bump devx from v0.50.2 to v0.51.0")
assert not CONVENTIONAL_RE.match("random message") assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:") assert not CONVENTIONAL_RE.match("feat:")
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something") assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
-1
View File
@@ -30,7 +30,6 @@ class TestHelpers:
assert CONVENTIONAL_RE.match("test: add tests") assert CONVENTIONAL_RE.match("test: add tests")
assert CONVENTIONAL_RE.match("ci: update workflow") assert CONVENTIONAL_RE.match("ci: update workflow")
assert CONVENTIONAL_RE.match("build: update deps") assert CONVENTIONAL_RE.match("build: update deps")
assert CONVENTIONAL_RE.match("deps: bump devx from v0.50.2 to v0.51.0")
assert CONVENTIONAL_RE.match("revert: undo change") assert CONVENTIONAL_RE.match("revert: undo change")
def test_conventional_re_allows_scope(self) -> None: def test_conventional_re_allows_scope(self) -> None: