diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index a7dcfa2..deffa30 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -1,13 +1,13 @@ name: Post-merge # Runs on every push to master. A single workflow with conditional jobs -# replaces separate workflows for release, wiki sync, badges, and -# Vikunja task updates. +# for release, publish, wiki sync, badges, and Vikunja task updates. # # Job dependency graph: # # detect-type ──┬── validate-commit-msg (skip if release commit) # ├── release (skip if release commit) +# │ └── publish (needs release — builds & publishes to PyPI) # ├── badges (ALWAYS runs — even on release commits) # ├── configure-repo (independent — skip if release commit) # ├── 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 # badges (tests, coverage, version, etc.) are always current. # -# When release creates a "release: vX.Y.Z" commit, the release -# commit's post-merge run still updates badges (version badge picks -# up the new version). Other jobs skip. The tag push triggers publish.yml. +# When release creates a "release: vX.Y.Z" commit and tag, the publish +# job (which depends on release) builds and publishes the package to the +# Gitea PyPI registry. The release commit's post-merge run still updates +# badges (version badge picks up the new version). Other jobs skip. on: push: @@ -74,6 +75,8 @@ jobs: if: needs.detect-type.outputs.is-release == 'false' runs-on: docker timeout-minutes: 15 + outputs: + tag: ${{ steps.release-tag.outputs.tag }} steps: - uses: actions/checkout@v4 with: @@ -88,12 +91,20 @@ jobs: git config user.name "devx-ci-bot" git config user.email "devx-ci-bot@oblachno.fyi" - name: Run release + id: release-tag env: PYTHONPATH: src run: | . .venv/bin/activate export PATH="$HOME/.local/bin:$PATH" 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 if: failure() env: @@ -108,6 +119,41 @@ jobs: --workflow "post-merge/release" \ --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: needs: [detect-type] if: needs.detect-type.outputs.is-release == 'false' diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml deleted file mode 100644 index 4e02575..0000000 --- a/.gitea/workflows/publish.yml +++ /dev/null @@ -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 }}" diff --git a/AGENTS.md b/AGENTS.md index dba5c6f..ad55803 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 changes still update the task tracker. -The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`) -which builds and publishes the package to the Gitea PyPI registry. +6. **publish** — Runs after release succeeds (needs: release). Builds and + 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 diff --git a/src/devx/ci/release.py b/src/devx/ci/release.py index 8238a38..5f117b5 100644 --- a/src/devx/ci/release.py +++ b/src/devx/ci/release.py @@ -317,6 +317,21 @@ def run_tests() -> None: 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: """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: # Ensure the existing tag is pushed run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False) + _write_github_output(tag) return False tag_msg = f"Release v{new_version}\n\n{changelog}" if dry_run: @@ -352,6 +368,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool return True run_cmd(["git", "tag", "-a", tag, "-m", tag_msg]) run_cmd(["git", "push", "origin", f"refs/tags/{tag}"]) + _write_github_output(tag) return True @@ -617,6 +634,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None: tag=release_tag, ) ) + _write_github_output(release_tag) return # Tag is missing — recover by creating and pushing it click.echo( diff --git a/src/devx/translations.json b/src/devx/translations.json index f5ee6d3..c72d62c 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -1846,5 +1846,13 @@ "pl": "[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" + }, + "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." } } diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py index 4948541..6e8e56b 100644 --- a/tests/unit/test_release.py +++ b/tests/unit/test_release.py @@ -1,5 +1,7 @@ """Unit tests for scripts/ci/release.py.""" +import os +from pathlib import Path from unittest.mock import MagicMock, patch import click @@ -815,11 +817,23 @@ class TestCommitReleaseChanges: class TestCreateAndPushTag: @patch("devx.ci.release.tag_exists", return_value=False) @patch("devx.ci.release.run_cmd") - def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: - create_and_push_tag("0.2.0", "changelog", dry_run=False) + def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock, tmp_path: Path) -> None: + 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] 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 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.run_cmd")