Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 269 additions & 0 deletions .github/scripts/propagate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""Open template-update PRs on the managed plugin repositories.

Reads the plugin list from the meta repository (plugins/*.json records are
the single source of truth) and runs `copier update` on each plugin
repository whose `.copier-answers.yml` records an older template commit.
Each update lands on a `template-update-<tag>` branch with a pull request
for human review.

Environment:
GITHUB_REPOSITORY owner/name of the template repository
GITHUB_REPOSITORY_OWNER organization login
GITHUB_STEP_SUMMARY summary file for the run report
Arguments:
--tag template tag to propagate, e.g. v0.0.6
--plugins JSON array of plugin names, e.g. ["source-record", ...]
--summary path of the GitHub step summary file
"""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from urllib.request import Request, urlopen


@dataclass
class Result:
plugin: str
repo: str
status: str
detail: str = ""


def api(url: str, token: str, method: str = "GET", body: dict | None = None) -> dict:
"""Call the GitHub REST API and return the parsed JSON response."""
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
data = json.dumps(body).encode() if body is not None else None
request = Request(url, headers=headers, data=data, method=method)
try:
with urlopen(request) as response:
return json.load(response)
except Exception as exc: # noqa: BLE001 - reported per plugin below
message = getattr(exc, "read", lambda: b"")()
raise RuntimeError(f"{method} {url} failed: {exc} {message}") from exc


def run(*args: str, cwd: Path | None = None) -> str:
"""Run a command, returning stdout, or failing loudly."""
result = subprocess.run(args, cwd=cwd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"{' '.join(args)} failed:\n{result.stdout}\n{result.stderr}"
)
return result.stdout.strip()


def recorded_commit(repo: str, token: str) -> str | None:
"""Return the template commit a repo was generated from, if any."""
url = f"https://api.github.com/repos/{repo}/contents/.copier-answers.yml"
try:
content = api(url, token)["content"]
except RuntimeError:
return None
import base64

text = base64.b64decode(content).decode()
for line in text.splitlines():
key, _, value = line.partition(":")
if key.strip() == "_commit":
return value.strip()
return None


def branch_exists(repo: str, branch: str, token: str) -> bool:
url = f"https://api.github.com/repos/{repo}/branches/{branch}"
try:
api(url, token)
except RuntimeError:
return False
return True


def pr_exists(repo: str, head: str, token: str) -> bool:
owner = repo.split("/", maxsplit=1)[0]
url = f"https://api.github.com/repos/{repo}/pulls?head={owner}:{head}&state=open"
return bool(api(url, token))


def template_changes(owner: str, old: str, tag: str, token: str) -> tuple[str, str]:
"""Summarize template history between old and tag for a PR body.

Returns a Markdown section with the commits in between (message and
author, from the compare API) and the files changed in the rendered
part of the template (paths outside template/ are development-only
and never reach a generated project).
"""
try:
comparison = api(
f"https://api.github.com/repos/{owner}/template/compare/{old}...{tag}",
token,
)
except RuntimeError:
return "", ""

commits = []
for c in comparison.get("commits", []):
sha = c["sha"][:7]
title = c["commit"]["message"].splitlines()[0]
commits.append(f"- [`{sha}`]({c['html_url']}) {title}")
files = [
f"- `{f['filename']}` ({f['status']})"
for f in comparison.get("files", [])
if f["filename"].startswith("template/")
]

sections = []
if commits:
sections.append("Commits:\n" + "\n".join(commits))
if files:
sections.append("Files in the template:\n" + "\n".join(files))
return "\n\n".join(sections), comparison["html_url"]


def repo_diffstat(work: Path) -> str:
"""Render the applied diff (what this repository actually got)."""
stat = run("git", "diff", "--stat", "HEAD", cwd=work)
untracked = run("git", "ls-files", "--others", "--exclude-standard", cwd=work)
lines = ["```", stat or "(no tracked-file changes)"]
if untracked:
lines.append("New files:")
lines += [f" {path}" for path in untracked.splitlines()]
lines.append("```")
return "\n".join(lines)


def update_one(plugin: str, repo: str, tag: str, token: str, owner: str) -> Result:
branch = f"template-update-{tag}"

if branch_exists(repo, branch, token) or pr_exists(repo, branch, token):
return Result(plugin, repo, "skipped", f"{branch} already exists")

commit = recorded_commit(repo, token)
if commit == tag:
return Result(plugin, repo, "skipped", f"already on {tag}")

with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) / "repo"
run(
"git",
"clone",
"--depth",
"50",
f"https://x-access-token:{token}@github.com/{repo}",
str(work),
)
# The machine user identity: its registered SSH signing key
# (repo secret CI_SSH_SIGNING_KEY) matches this noreply email, so
# commits render as Verified.
run("git", "config", "user.name", "obs-plugins-snaps-bot")
run(
"git",
"config",
"user.email",
"346340553+obs-plugins-snaps-bot@users.noreply.github.com",
cwd=work,
)

copier = ["uvx", "--from", "copier", "copier"]
run(*copier, "update", "--defaults", "--vcs-ref", tag, cwd=work)

if not run("git", "status", "--porcelain", cwd=work):
return Result(plugin, repo, "skipped", "no changes rendered")

diffstat = repo_diffstat(work)

run("git", "checkout", "-b", branch, cwd=work)
run("git", "add", "-A", cwd=work)
run(
"git",
"commit",
"-m",
f"Update template to {tag}",
cwd=work,
)
run("git", "push", "-u", "origin", branch, cwd=work)

old = commit or "unknown"
compare = f"https://github.com/{owner}/template/compare/{old}...{tag}"
changes, compare_url = template_changes(owner, old, tag, token)
if compare_url:
compare = compare_url

body_parts = [
f"Automatic template update from `{old}` to `{tag}`.",
f"[Full template comparison]({compare})",
]
if changes:
body_parts.append(f"## Template changes ({old} → {tag})\n\n{changes}")
body_parts.append(f"## Applied to this repository\n\n{diffstat}")
body_parts.append(
"Generated with `copier update --defaults --vcs-ref "
f"{tag}`; conflicts (if any) are marked in the files "
"for manual resolution."
)

api(
f"https://api.github.com/repos/{repo}/pulls",
token,
method="POST",
body={
"title": f"Update template to {tag}",
"head": branch,
"base": "main",
"body": "\n\n".join(body_parts) + "\n",
},
)
return Result(plugin, repo, "updated", f"PR opened ({old} -> {tag})")


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--tag", required=True)
parser.add_argument("--plugins", required=True)
parser.add_argument("--summary", required=True)
args = parser.parse_args()

token = os.environ["GH_TOKEN"]
owner = os.environ["GITHUB_REPOSITORY_OWNER"]
plugins: list[str] = json.loads(args.plugins)

results: list[Result] = []
for plugin in plugins:
repo = f"{owner}/{plugin}-obs-studio-snap"
try:
# Fail early with a clear message when the repository does not
# exist yet (record present, repo not created).
api(f"https://api.github.com/repos/{repo}", token)
results.append(update_one(plugin, repo, args.tag, token, owner))
except RuntimeError as exc:
text = str(exc)
status = "missing repo" if "404" in text else "failed"
results.append(Result(plugin, repo, status, text[:200]))

lines = [
"| Plugin | Repository | Status | Detail |",
"|---|---|---|---|",
]
for r in results:
lines.append(f"| {r.plugin} | {r.repo} | {r.status} | {r.detail} |")
Path(args.summary).write_text("\n".join(lines) + "\n")

failures = [r for r in results if r.status == "failed"]
if failures:
sys.exit(f"{len(failures)} plugin(s) failed; see the summary")


if __name__ == "__main__":
main()
99 changes: 99 additions & 0 deletions .github/workflows/propagate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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/<plugin_name>-obs-studio.json record in
# https://github.com/obs-plugins-snaps/meta maps to the repository
# <plugin_name>-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("(?<p>.+)-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"
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ ignore = [

[tool.ruff.lint.per-file-ignores]
"tests/**/*" = ["D", "PLR2004"]
# Automation scripts shipped in workflows: stdlib-only, no public API.
".github/scripts/*" = ["D101", "D103", "PLW1510", "PLC0415"]