From 55a85997d35b5c6c84216c76747901c9a5f34429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Luis=20Cano=20Rodr=C3=ADguez?= Date: Thu, 22 Oct 2026 18:44:48 +0200 Subject: [PATCH 1/2] ci: unify test, tag and propagate in one workflow Three workflows with chained triggers (test on push, tag on push, propagate via workflow_run of the tag workflow) made the pipeline relationship invisible in the runs list. Merge them into a single Template CI workflow with three jobs: - pull_request: test only (PR checks unchanged) - push to main: test -> tag -> propagate; tests now gate the tag, so a broken main no longer propagates to the plugins - workflow_dispatch: propagate only, as before Permissions, job names and concurrency behavior are preserved; the propagate job keeps resolving the latest tag at runtime, which also covers manual tag pushes and manual re-runs. --- .github/workflows/ci.yaml | 215 +++++++++++++++++++++++++++++++ .github/workflows/propagate.yaml | 99 -------------- .github/workflows/tag.yaml | 93 ------------- .github/workflows/test.yaml | 28 ---- 4 files changed, 215 insertions(+), 220 deletions(-) create mode 100644 .github/workflows/ci.yaml delete mode 100644 .github/workflows/propagate.yaml delete mode 100644 .github/workflows/tag.yaml delete mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1683c7c --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,215 @@ +# Template CI: one workflow, one run per event, so the whole pipeline is +# visible in a single place. +# +# - pull_request: run the tests (lint + pytest-copie) only. +# - push to main: test -> tag -> propagate, chained with `needs`, so a +# red test blocks the tag and nothing broken is propagated to the +# plugin repositories. +# - workflow_dispatch: propagate only (optionally a specific tag), for +# manual re-runs. +# +# The tag job tags every commit to main with the next patch version, so +# that projects generated from this template can always `copier update` +# to the latest merged state. Minor and major bumps are made manually by +# pushing a tag; if the head commit is already tagged, the automatic +# bump is a no-op and propagation picks up the latest tag (the manual +# one), just like it does after an automatic bump. +# +# The list of managed plugins is read from meta at runtime by the +# propagate job: every plugins/-obs-studio.json record in +# https://github.com/obs-plugins-snaps/meta maps to the repository +# -obs-studio-snap. meta is the single source of truth; +# nothing here edits it. + +name: Template CI + +on: + pull_request: + paths: + - "copier.yaml" + - "template/**" + - "tests/**" + - "pyproject.toml" + push: + branches: [main] + workflow_dispatch: + inputs: + tag: + description: Template tag to propagate (defaults to the latest v* tag) + required: false + +permissions: {} + +jobs: + test: + name: Test template + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - uses: astral-sh/setup-uv@v7 + + - name: Run lint + run: uv run ruff check . && uv run ruff format --check . + + - name: Run tests + run: uv run pytest tests/ -v + + tag: + name: 🏷️ Tag with the next patch version + needs: test + if: github.event_name == 'push' + runs-on: ubuntu-latest + concurrency: + # Serialize tag bumps so that two pushes to main never race to + # push the next version. + group: tag-${{ github.ref }} + cancel-in-progress: false + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: 🏷️ Create and push the next patch tag + env: + initial_version: v0.0.1 + run: | + import os + import re + import subprocess + import sys + + initial_version = os.environ["initial_version"] + semver = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + + def git(*args): + """Run git, returning stdout, or failing loudly.""" + result = subprocess.run(["git", *args], capture_output=True, text=True) + if result.returncode != 0: + sys.exit(f"git {' '.join(args)} failed:\n{result.stderr.strip()}") + return result.stdout.strip() + + def fail(message): + sys.exit(f"::error::{message}") + + git("fetch", "--tags", "--force") + + # A manually pushed tag wins over the automatic bump: if the head + # commit is already tagged (e.g. a manual minor or major bump), + # there is nothing to do. + if git("tag", "--list", "v*", "--points-at", "HEAD"): + print("HEAD is already tagged; skipping the automatic bump.") + sys.exit(0) + + tags = git("tag", "--list", "v*").splitlines() + if not tags: + print(f"No existing tags; starting at {initial_version}.") + next_version = initial_version + elif any(not semver.match(tag) for tag in tags): + bad = [tag for tag in tags if not semver.match(tag)] + fail( + f"Tags {', '.join(bad)} are not vMAJOR.MINOR.PATCH versions; " + "cannot bump automatically." + ) + else: + # Strict semver comparison, so bumps carry over 9 -> 10. + major, minor, patch = max( + tuple(int(group) for group in semver.match(tag).groups()) + for tag in tags + ) + next_version = f"v{major}.{minor}.{patch + 1}" + + tag_exists = ( + subprocess.run( + ["git", "rev-parse", "-q", "--verify", f"refs/tags/{next_version}"], + capture_output=True, + ).returncode + == 0 + ) + if tag_exists: + fail(f"Tag {next_version} already exists; refusing to overwrite.") + + print(f"Tagging HEAD as {next_version}.") + git("tag", next_version) + git("push", "origin", next_version) + shell: python + + propagate: + name: Open update PRs on plugin repositories + needs: tag + if: >- + always() && + (needs.tag.result == 'success' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + concurrency: + group: propagate-template + cancel-in-progress: false + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + # copier update needs the history around the recorded _commit. + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v7 + + - name: Configure git commit signing + env: + CI_SSH_SIGNING_KEY: ${{ secrets.CI_SSH_SIGNING_KEY }} + run: | + mkdir -p ~/.ssh + echo "$CI_SSH_SIGNING_KEY" > ~/.ssh/signing-key + chmod 600 ~/.ssh/signing-key + git config --global gpg.format ssh + git config --global user.signingkey ~/.ssh/signing-key + git config --global commit.gpgsign true + + - name: Generate installation token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Resolve the latest template tag + id: tag + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG: ${{ inputs.tag }} + run: | + if [ -n "$INPUT_TAG" ]; then + echo "version=$INPUT_TAG" >> "$GITHUB_OUTPUT" + exit 0 + fi + latest=$(gh api "repos/$GITHUB_REPOSITORY/tags" \ + --jq '[.[].name | select(startswith("v"))][0]') + echo "version=$latest" >> "$GITHUB_OUTPUT" + + - name: List plugin records in meta + id: plugins + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + INPUT_TAG: ${{ inputs.tag }} + run: | + names=$(gh api "repos/$GITHUB_REPOSITORY_OWNER/meta/contents/plugins" \ + --jq '[.[].name | select(endswith("-obs-studio.json")) | + capture("(?

.+)-obs-studio\\.json").p]') + echo "plugins=$names" >> "$GITHUB_OUTPUT" + + - name: Open update PRs + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TEMPLATE_TAG: ${{ steps.tag.outputs.version }} + PLUGINS: ${{ steps.plugins.outputs.plugins }} + run: | + uvx --from copier copier --version + uv run .github/scripts/propagate.py \ + --tag "$TEMPLATE_TAG" \ + --plugins "$PLUGINS" \ + --summary "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/propagate.yaml b/.github/workflows/propagate.yaml deleted file mode 100644 index 5b57ed4..0000000 --- a/.github/workflows/propagate.yaml +++ /dev/null @@ -1,99 +0,0 @@ -# Propagate template updates to plugin repositories. - -# Runs after the auto-tag workflow completes on main: when a new template -# tag exists, open one update PR per managed plugin repository so their -# scaffolding follows the template. -# -# The list of managed plugins is read from meta at runtime: every -# plugins/-obs-studio.json record in -# https://github.com/obs-plugins-snaps/meta maps to the repository -# -obs-studio-snap. meta is the single source of truth; this -# workflow never edits it. - -name: Propagate template updates - -"on": - workflow_run: - workflows: [Tag commit] - types: [completed] - branches: [main] - workflow_dispatch: - inputs: - tag: - description: Template tag to propagate (defaults to the latest v* tag) - required: false - -concurrency: - group: propagate-template - cancel-in-progress: false - -permissions: - contents: read - -jobs: - propagate: - name: Open update PRs on plugin repositories - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - # copier update needs the history around the recorded _commit. - fetch-depth: 0 - - - uses: astral-sh/setup-uv@v7 - - - name: Configure git commit signing - env: - CI_SSH_SIGNING_KEY: ${{ secrets.CI_SSH_SIGNING_KEY }} - run: | - mkdir -p ~/.ssh - echo "$CI_SSH_SIGNING_KEY" > ~/.ssh/signing-key - chmod 600 ~/.ssh/signing-key - git config --global gpg.format ssh - git config --global user.signingkey ~/.ssh/signing-key - git config --global commit.gpgsign true - - - name: Generate installation token - id: app-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.APP_CLIENT_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Resolve the latest template tag - id: tag - env: - GH_TOKEN: ${{ github.token }} - INPUT_TAG: ${{ inputs.tag }} - run: | - if [ -n "$INPUT_TAG" ]; then - echo "version=$INPUT_TAG" >> "$GITHUB_OUTPUT" - exit 0 - fi - latest=$(gh api "repos/$GITHUB_REPOSITORY/tags" \ - --jq '[.[].name | select(startswith("v"))][0]') - echo "version=$latest" >> "$GITHUB_OUTPUT" - - - name: List plugin records in meta - id: plugins - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - INPUT_TAG: ${{ inputs.tag }} - run: | - names=$(gh api "repos/$GITHUB_REPOSITORY_OWNER/meta/contents/plugins" \ - --jq '[.[].name | select(endswith("-obs-studio.json")) | - capture("(?

.+)-obs-studio\\.json").p]') - echo "plugins=$names" >> "$GITHUB_OUTPUT" - - - name: Open update PRs - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - TEMPLATE_TAG: ${{ steps.tag.outputs.version }} - PLUGINS: ${{ steps.plugins.outputs.plugins }} - run: | - uvx --from copier copier --version - uv run .github/scripts/propagate.py \ - --tag "$TEMPLATE_TAG" \ - --plugins "$PLUGINS" \ - --summary "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml deleted file mode 100644 index a1a6624..0000000 --- a/.github/workflows/tag.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# Tags every commit to main with the next patch version, so that projects -# generated from this template can always `copier update` to the latest -# merged state. Minor and major bumps are made manually by pushing a tag; -# if the head commit is already tagged, the automatic bump is a no-op and -# the next one continues from the manual tag. - -name: Tag commit - -on: - push: - branches: [main] - -concurrency: - # Serialize tag bumps so that two pushes to main never race to push the - # next version. - group: tag-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: write - -jobs: - tag: - name: 🏷️ Tag with the next patch version - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: 🏷️ Create and push the next patch tag - env: - initial_version: v0.0.1 - run: | - import os - import re - import subprocess - import sys - - initial_version = os.environ["initial_version"] - semver = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") - - def git(*args): - """Run git, returning stdout, or failing loudly.""" - result = subprocess.run(["git", *args], capture_output=True, text=True) - if result.returncode != 0: - sys.exit(f"git {' '.join(args)} failed:\n{result.stderr.strip()}") - return result.stdout.strip() - - def fail(message): - sys.exit(f"::error::{message}") - - git("fetch", "--tags", "--force") - - # A manually pushed tag wins over the automatic bump: if the head - # commit is already tagged (e.g. a manual minor or major bump), - # there is nothing to do. - if git("tag", "--list", "v*", "--points-at", "HEAD"): - print("HEAD is already tagged; skipping the automatic bump.") - sys.exit(0) - - tags = git("tag", "--list", "v*").splitlines() - if not tags: - print(f"No existing tags; starting at {initial_version}.") - next_version = initial_version - elif any(not semver.match(tag) for tag in tags): - bad = [tag for tag in tags if not semver.match(tag)] - fail( - f"Tags {', '.join(bad)} are not vMAJOR.MINOR.PATCH versions; " - "cannot bump automatically." - ) - else: - # Strict semver comparison, so bumps carry over 9 -> 10. - major, minor, patch = max( - tuple(int(group) for group in semver.match(tag).groups()) - for tag in tags - ) - next_version = f"v{major}.{minor}.{patch + 1}" - - tag_exists = ( - subprocess.run( - ["git", "rev-parse", "-q", "--verify", f"refs/tags/{next_version}"], - capture_output=True, - ).returncode - == 0 - ) - if tag_exists: - fail(f"Tag {next_version} already exists; refusing to overwrite.") - - print(f"Tagging HEAD as {next_version}.") - git("tag", next_version) - git("push", "origin", next_version) - shell: python diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index e096545..0000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: Test template - -on: - pull_request: - paths: - - "copier.yaml" - - "template/**" - - "tests/**" - - "pyproject.toml" - push: - branches: [main] - -jobs: - test: - name: Test template - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v7 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - - - name: Run lint - run: uv run ruff check . && uv run ruff format --check . - - - name: Run tests - run: uv run pytest tests/ -v From 6976ed36c4fe406840b4e51f88b270fc368e5f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Luis=20Cano=20Rodr=C3=ADguez?= Date: Thu, 22 Oct 2026 18:51:14 +0200 Subject: [PATCH 2/2] ci: also run the tests on workflow and tooling changes Workflow-only PRs (like this one) matched none of the pull_request paths and got no CI at all. Include .github/** so changes to the pipeline itself are at least linted. --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1683c7c..cd5aa6f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,6 +26,7 @@ name: Template CI on: pull_request: paths: + - ".github/**" - "copier.yaml" - "template/**" - "tests/**"