DEVX-65: refactor: consolidate publish.yml into post-merge.yml
Post-merge / detect-type (push) Successful in 30s
Post-merge / vikunja (push) Successful in 46s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / validate-commit-msg (push) Successful in 51s
Post-merge / badges (push) Successful in 59s
Post-merge / release (push) Successful in 1m30s
Post-merge / configure-repo (push) Successful in 1m34s
Post-merge / publish (push) Successful in 54s

This commit was merged in pull request #105.
This commit is contained in:
2026-06-26 19:34:45 +00:00
parent 5063f659bc
commit 7c1ecd6ff9
6 changed files with 96 additions and 55 deletions
+51 -5
View File
@@ -1,13 +1,13 @@
name: Post-merge name: Post-merge
# Runs on every push to master. A single workflow with conditional jobs # Runs on every push to master. A single workflow with conditional jobs
# replaces separate workflows for release, wiki sync, badges, and # for release, publish, wiki sync, badges, and Vikunja task updates.
# Vikunja task updates.
# #
# Job dependency graph: # Job dependency graph:
# #
# detect-type ──┬── validate-commit-msg (skip if release commit) # detect-type ──┬── validate-commit-msg (skip if release commit)
# ├── release (skip if release commit) # ├── release (skip if release commit)
# │ └── publish (needs release — builds & publishes to PyPI)
# ├── badges (ALWAYS runs — even on release commits) # ├── badges (ALWAYS runs — even on release commits)
# ├── configure-repo (independent — skip if release commit) # ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (skip if release commit — runs for ALL merges) # ├── sync-wiki (skip if release commit — runs for ALL merges)
@@ -21,9 +21,10 @@ name: Post-merge
# runs on every push to master, including release commits. This ensures # runs on every push to master, including release commits. This ensures
# badges (tests, coverage, version, etc.) are always current. # badges (tests, coverage, version, etc.) are always current.
# #
# When release creates a "release: vX.Y.Z" commit, the release # When release creates a "release: vX.Y.Z" commit and tag, the publish
# commit's post-merge run still updates badges (version badge picks # job (which depends on release) builds and publishes the package to the
# up the new version). Other jobs skip. The tag push triggers publish.yml. # Gitea PyPI registry. The release commit's post-merge run still updates
# badges (version badge picks up the new version). Other jobs skip.
on: on:
push: push:
@@ -74,6 +75,8 @@ jobs:
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
timeout-minutes: 15 timeout-minutes: 15
outputs:
tag: ${{ steps.release-tag.outputs.tag }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -88,12 +91,20 @@ jobs:
git config user.name "devx-ci-bot" git config user.name "devx-ci-bot"
git config user.email "devx-ci-bot@oblachno.fyi" git config user.email "devx-ci-bot@oblachno.fyi"
- name: Run release - name: Run release
id: release-tag
env: env:
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release python3 -m devx.ci.release
- name: Extract tag (fallback if GITHUB_OUTPUT not set)
if: steps.release-tag.outputs.tag == ''
run: |
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
if [ -n "$tag" ]; then
echo "tag=$tag" >> "$GITHUB_OUTPUT"
fi
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
@@ -108,6 +119,41 @@ jobs:
--workflow "post-merge/release" \ --workflow "post-merge/release" \
--commit "${{ github.sha }}" --commit "${{ github.sha }}"
publish:
needs: [release]
if: needs.release.outputs.tag != ''
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Build and publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
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 "post-merge/publish" \
--commit "${{ github.sha }}"
sync-wiki: sync-wiki:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
-46
View File
@@ -1,46 +0,0 @@
name: Publish Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g. v0.9.11)'
required: true
type: string
jobs:
publish:
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Build and publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ github.event.inputs.tag || github.ref_name }}" "${{ github.repository }}"
- name: Notify on failure
if: failure()
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
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 "publish" \
--commit "${{ github.sha }}"
+3 -2
View File
@@ -204,8 +204,9 @@ After a PR is merged to master, the **post-merge workflow**
non-release commits (not just when release succeeds), so infrastructure-only non-release commits (not just when release succeeds), so infrastructure-only
changes still update the task tracker. changes still update the task tracker.
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`) 6. **publish** — Runs after release succeeds (needs: release). Builds and
which builds and publishes the package to the Gitea PyPI registry. publishes the package to the Gitea PyPI registry. Gets the tag from the
release job's `tag` output (written via `GITHUB_OUTPUT`).
### Smart CI: User-Facing vs Workflow-Only Changes ### Smart CI: User-Facing vs Workflow-Only Changes
+18
View File
@@ -317,6 +317,21 @@ def run_tests() -> None:
click.echo(_("Tests passed.")) click.echo(_("Tests passed."))
def _write_github_output(tag: str) -> None:
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
This allows a publish job (needs: release) to read the tag via
``${{ needs.release.outputs.tag }}`` instead of relying on
tag-push event triggering a separate workflow.
"""
github_output = os.environ.get("GITHUB_OUTPUT")
if not github_output:
return
with open(github_output, "a") as f: # noqa: PTH123
f.write(f"tag={tag}\n")
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool: def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
"""Create an annotated tag with the changelog as message and push it. """Create an annotated tag with the changelog as message and push it.
@@ -345,6 +360,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
if not dry_run: if not dry_run:
# Ensure the existing tag is pushed # Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
_write_github_output(tag)
return False return False
tag_msg = f"Release v{new_version}\n\n{changelog}" tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run: if dry_run:
@@ -352,6 +368,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
return True return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg]) run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"]) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
_write_github_output(tag)
return True return True
@@ -617,6 +634,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
tag=release_tag, tag=release_tag,
) )
) )
_write_github_output(release_tag)
return return
# Tag is missing — recover by creating and pushing it # Tag is missing — recover by creating and pushing it
click.echo( click.echo(
+8
View File
@@ -1846,5 +1846,13 @@
"pl": "[check-dep-docs] Passed: all dependencies are documented", "pl": "[check-dep-docs] Passed: all dependencies are documented",
"ru": "[check-dep-docs] Passed: all dependencies are documented", "ru": "[check-dep-docs] Passed: all dependencies are documented",
"zh": "[check-dep-docs] Passed: all dependencies are documented" "zh": "[check-dep-docs] Passed: all dependencies are documented"
},
"Wrote tag {tag} to GITHUB_OUTPUT.": {
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
} }
} }
+16 -2
View File
@@ -1,5 +1,7 @@
"""Unit tests for scripts/ci/release.py.""" """Unit tests for scripts/ci/release.py."""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click import click
@@ -815,11 +817,23 @@ class TestCommitReleaseChanges:
class TestCreateAndPushTag: class TestCreateAndPushTag:
@patch("devx.ci.release.tag_exists", return_value=False) @patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock, tmp_path: Path) -> None:
create_and_push_tag("0.2.0", "changelog", dry_run=False) github_output = tmp_path / "output.txt"
with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output)}):
create_and_push_tag("0.2.0", "changelog", dry_run=False)
calls = [c.args[0] for c in mock_run_cmd.call_args_list] calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls assert ["git", "push", "origin", "refs/tags/v0.2.0"] in calls
assert github_output.read_text() == "tag=v0.2.0\n"
@patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd")
def test_no_github_output_skips_write(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
with patch.dict(os.environ, {}, clear=True):
create_and_push_tag("0.2.0", "changelog", dry_run=False)
# Should still create tag, just not write GITHUB_OUTPUT
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
@patch("devx.ci.release.tag_exists", return_value=False) @patch("devx.ci.release.tag_exists", return_value=False)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")