Skip to content

Commit 560dba1

Browse files
feat: workflow to contribute patches back to source
1 parent a37b0e8 commit 560dba1

4 files changed

Lines changed: 526 additions & 2 deletions

File tree

src/syncweaver/templates/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
```sh
88
syncweaver templates list
99
syncweaver templates add syncweaver-update-source --output .github/workflows/
10-
syncweaver templates add syncweaver-notify-host-update-source --output .github/workflows/
10+
syncweaver templates add syncweaver-contribute-patch --output .github/workflows/
1111
```
1212
1313
Use `available_templates_markdown()` to render the current template inventory.
@@ -17,7 +17,7 @@
1717
import pathlib
1818

1919
_TEMPLATE_DESCRIPTIONS = {
20-
"syncweaver-notify-host-update-source.yml": "On source release, dispatch a host repo to run syncweaver-update-source",
20+
"syncweaver-contribute-patch.yml": "Apply a tracked host patch to a source repo and open a pull request on workflow dispatch",
2121
"syncweaver-update-source.yml": "Run syncweaver update from a workflow dispatch or repository dispatch",
2222
}
2323

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
name: syncweaver-contribute-patch
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
source_path:
7+
description: Optional tracked source path override; if omitted, source_path is resolved from lockfile.
8+
required: false
9+
type: string
10+
repo_url:
11+
description: Optional source repository identifier (URL or OWNER/REPO) used to resolve source_path from lockfile.
12+
required: false
13+
type: string
14+
source_repository:
15+
description: Optional source repository in OWNER/REPO format; if omitted, derives from lockfile repo_url.
16+
required: false
17+
type: string
18+
lockfile:
19+
description: Path to the syncweaver lockfile.
20+
required: false
21+
default: .syncweaver-lock.json
22+
type: string
23+
patch_path:
24+
description: Optional patch path override; if omitted, uses source entry patch value from lockfile.
25+
required: false
26+
type: string
27+
source_base_ref:
28+
description: Optional source repository base branch for PR; if omitted, uses source entry ref from lockfile.
29+
required: false
30+
type: string
31+
32+
permissions:
33+
contents: read
34+
35+
jobs:
36+
resolve-patch-metadata:
37+
runs-on: ubuntu-latest
38+
outputs:
39+
source_path: ${{ steps.resolve.outputs.source_path }}
40+
repo_url: ${{ steps.resolve.outputs.repo_url }}
41+
source_repository: ${{ steps.resolve.outputs.source_repository }}
42+
patch_path: ${{ steps.resolve.outputs.patch_path }}
43+
source_base_ref: ${{ steps.resolve.outputs.source_base_ref }}
44+
lockfile: ${{ env.LOCKFILE }}
45+
env:
46+
SOURCE_PATH: ${{ github.event.inputs.source_path || '' }}
47+
REPO_URL: ${{ github.event.inputs.repo_url || '' }}
48+
SOURCE_REPOSITORY: ${{ github.event.inputs.source_repository || '' }}
49+
LOCKFILE: ${{ github.event.inputs.lockfile || '.syncweaver-lock.json' }}
50+
PATCH_PATH: ${{ github.event.inputs.patch_path || '' }}
51+
SOURCE_BASE_REF: ${{ github.event.inputs.source_base_ref || '' }}
52+
steps:
53+
- name: Checkout host repository
54+
uses: actions/checkout@v6
55+
56+
- name: Set up Python
57+
uses: actions/setup-python@v6
58+
with:
59+
python-version: "3.14"
60+
cache: pip
61+
62+
- name: Install syncweaver
63+
run: |
64+
python -m pip install --upgrade pip
65+
python -m pip install "git+https://github.com/CCBR/syncweaver.git@main"
66+
67+
- name: Resolve source and patch metadata
68+
id: resolve
69+
shell: bash
70+
env:
71+
SOURCE_PATH_INPUT: ${{ env.SOURCE_PATH }}
72+
REPO_URL_INPUT: ${{ env.REPO_URL }}
73+
SOURCE_REPOSITORY_INPUT: ${{ env.SOURCE_REPOSITORY }}
74+
LOCKFILE_PATH: ${{ env.LOCKFILE }}
75+
PATCH_PATH_INPUT: ${{ env.PATCH_PATH }}
76+
SOURCE_BASE_REF_INPUT: ${{ env.SOURCE_BASE_REF }}
77+
SYNCWEAVER_HOST_CWD: ${{ github.workspace }}
78+
run: |
79+
python -m syncweaver.workflow_contribute_patch
80+
81+
- name: Print resolved metadata
82+
shell: bash
83+
run: |
84+
set -euo pipefail
85+
echo "source_path=${{ steps.resolve.outputs.source_path }}"
86+
echo "repo_url=${{ steps.resolve.outputs.repo_url }}"
87+
echo "source_repository=${{ steps.resolve.outputs.source_repository }}"
88+
echo "patch_path=${{ steps.resolve.outputs.patch_path }}"
89+
echo "source_base_ref=${{ steps.resolve.outputs.source_base_ref }}"
90+
91+
contribute-patch:
92+
runs-on: ubuntu-latest
93+
needs: resolve-patch-metadata
94+
permissions:
95+
contents: read
96+
env:
97+
SOURCE_PATH: ${{ needs.resolve-patch-metadata.outputs.source_path }}
98+
REPO_URL: ${{ needs.resolve-patch-metadata.outputs.repo_url }}
99+
SOURCE_REPOSITORY: ${{ needs.resolve-patch-metadata.outputs.source_repository }}
100+
PATCH_PATH: ${{ needs.resolve-patch-metadata.outputs.patch_path }}
101+
SOURCE_BASE_REF: ${{ needs.resolve-patch-metadata.outputs.source_base_ref }}
102+
LOCKFILE: ${{ needs.resolve-patch-metadata.outputs.lockfile }}
103+
steps:
104+
- name: Generate CCBR-bot token
105+
id: ccbr_bot
106+
uses: actions/create-github-app-token@v1
107+
with:
108+
app-id: ${{ secrets.CCBR_BOT_APP_ID }}
109+
private-key: ${{ secrets.CCBR_BOT_PRIVATE_KEY }}
110+
111+
- name: Checkout host repository
112+
uses: actions/checkout@v6
113+
with:
114+
path: host
115+
116+
- name: Checkout source repository
117+
uses: actions/checkout@v6
118+
with:
119+
repository: ${{ env.SOURCE_REPOSITORY }}
120+
ref: ${{ env.SOURCE_BASE_REF }}
121+
token: ${{ steps.ccbr_bot.outputs.token }}
122+
path: source
123+
124+
- name: Apply patch to source repository
125+
id: apply_patch
126+
shell: bash
127+
run: |
128+
set -euo pipefail
129+
130+
patch_file="$GITHUB_WORKSPACE/host/${PATCH_PATH}"
131+
if [[ ! -f "$patch_file" ]]; then
132+
echo "Error: patch file not found in host repository: ${PATCH_PATH}" >&2
133+
exit 1
134+
fi
135+
136+
branch_stub=$(printf '%s' "$SOURCE_PATH" | tr '/ ' '--' | tr -cd '[:alnum:]._-')
137+
if [[ -z "$branch_stub" ]]; then
138+
branch_stub="patch"
139+
fi
140+
branch_name="syncweaver/contribute-patch/${branch_stub}-${GITHUB_RUN_ID}"
141+
142+
cd "$GITHUB_WORKSPACE/source"
143+
git config user.name "github-actions[bot]"
144+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
145+
git switch -c "$branch_name"
146+
147+
if ! git apply --3way --whitespace=nowarn "$patch_file"; then
148+
echo "Error: patch failed to apply cleanly to ${SOURCE_REPOSITORY}@${SOURCE_BASE_REF}" >&2
149+
exit 1
150+
fi
151+
152+
if git diff --quiet; then
153+
echo "has_changes=false" >> "$GITHUB_OUTPUT"
154+
else
155+
echo "has_changes=true" >> "$GITHUB_OUTPUT"
156+
fi
157+
echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT"
158+
159+
- name: Open pull request in source repository
160+
if: steps.apply_patch.outputs.has_changes == 'true'
161+
uses: peter-evans/create-pull-request@v7
162+
with:
163+
token: ${{ steps.ccbr_bot.outputs.token }}
164+
path: source
165+
base: ${{ env.SOURCE_BASE_REF }}
166+
branch: ${{ steps.apply_patch.outputs.branch_name }}
167+
delete-branch: true
168+
commit-message: "chore(syncweaver): apply host patch for ${{ env.SOURCE_PATH }}"
169+
title: "chore(syncweaver): apply host patch for ${{ env.SOURCE_PATH }}"
170+
body: |
171+
Automated patch contribution from host repository workflow dispatch.
172+
173+
Inputs used:
174+
- source_path: ${{ env.SOURCE_PATH }}
175+
- repo_url: ${{ env.REPO_URL }}
176+
- source_repository: ${{ env.SOURCE_REPOSITORY }}
177+
- source_base_ref: ${{ env.SOURCE_BASE_REF }}
178+
- patch_path: ${{ env.PATCH_PATH }}
179+
- lockfile: ${{ env.LOCKFILE }}
180+
181+
Trigger context:
182+
- host_repository: ${{ github.repository }}
183+
- workflow_run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
184+
185+
- name: Print no-op summary
186+
if: steps.apply_patch.outputs.has_changes == 'false'
187+
shell: bash
188+
run: |
189+
set -euo pipefail
190+
echo "Patch applied cleanly but introduced no source changes. No PR created."
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Resolve contribute-patch workflow metadata for host repositories."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import pathlib
7+
from urllib.parse import urlparse
8+
9+
from syncweaver.lockfile import (
10+
load_existing_lockfile,
11+
resolve_source_path_from_lockfile,
12+
)
13+
14+
15+
def _repo_slug_from_url(url: str) -> str:
16+
"""Extract OWNER/REPO slug from a normalized repository URL."""
17+
parsed = urlparse(url.strip())
18+
if parsed.scheme not in {"http", "https"}:
19+
raise ValueError(f"Cannot derive repository slug from: {url}")
20+
21+
path_parts = [part for part in parsed.path.split("/") if part]
22+
if len(path_parts) < 2:
23+
raise ValueError(f"Cannot derive repository slug from: {url}")
24+
25+
owner = path_parts[0]
26+
repo = path_parts[1]
27+
slug = f"{owner}/{repo}"
28+
return slug
29+
30+
31+
def resolve_contribute_patch_metadata(
32+
*,
33+
lockfile: pathlib.Path,
34+
host_cwd: pathlib.Path,
35+
source_path: str | None,
36+
repo_url: str | None,
37+
source_repository: str | None,
38+
patch_path: str | None,
39+
source_base_ref: str | None,
40+
) -> dict[str, str]:
41+
"""Resolve workflow inputs into concrete source and patch metadata.
42+
43+
Args:
44+
lockfile: Path to the syncweaver lockfile in the host repository.
45+
host_cwd: Host repository working directory used for resolving relative paths.
46+
source_path: Optional tracked source path override.
47+
repo_url: Optional source repository URL or OWNER/REPO shorthand.
48+
source_repository: Optional explicit OWNER/REPO source repository.
49+
patch_path: Optional explicit patch file path.
50+
source_base_ref: Optional explicit source repository base ref.
51+
52+
Returns:
53+
dict[str, str]: Resolved metadata fields required by the workflow.
54+
55+
Raises:
56+
ValueError: If lockfile metadata is missing or ambiguous.
57+
FileNotFoundError: If resolved patch file does not exist.
58+
"""
59+
source_path_input = ""
60+
repo_url_input = ""
61+
source_repository_input = ""
62+
patch_path_input = ""
63+
source_base_ref_input = ""
64+
65+
if source_path is not None:
66+
source_path_input = source_path.strip()
67+
if repo_url is not None:
68+
repo_url_input = repo_url.strip()
69+
if source_repository is not None:
70+
source_repository_input = source_repository.strip()
71+
if patch_path is not None:
72+
patch_path_input = patch_path.strip()
73+
if source_base_ref is not None:
74+
source_base_ref_input = source_base_ref.strip()
75+
76+
repo_selector = repo_url_input
77+
if not repo_selector and source_repository_input:
78+
repo_selector = source_repository_input
79+
80+
resolved_source_path = resolve_source_path_from_lockfile(
81+
lockfile=lockfile,
82+
source_path=source_path_input,
83+
repo_url=repo_selector,
84+
)
85+
86+
lock_data = load_existing_lockfile(lockfile)
87+
sources = lock_data.get("sources", {})
88+
source_entry_raw = sources.get(resolved_source_path)
89+
if not isinstance(source_entry_raw, dict):
90+
raise ValueError(
91+
f"lockfile source entry is invalid for source_path: {resolved_source_path}"
92+
)
93+
94+
resolved_repo_url = str(source_entry_raw.get("repo_url", "")).strip()
95+
if not resolved_repo_url:
96+
raise ValueError(
97+
"lockfile source entry is missing repo_url for source_path: "
98+
f"{resolved_source_path}"
99+
)
100+
101+
resolved_source_repository = source_repository_input
102+
if not resolved_source_repository:
103+
resolved_source_repository = _repo_slug_from_url(resolved_repo_url)
104+
105+
resolved_patch_path = patch_path_input
106+
if not resolved_patch_path:
107+
resolved_patch_path = str(source_entry_raw.get("patch", "")).strip()
108+
if not resolved_patch_path:
109+
raise ValueError(
110+
"no patch path was provided and no tracked patch exists in lockfile "
111+
f"for source_path: {resolved_source_path}"
112+
)
113+
114+
patch_file = (host_cwd / pathlib.Path(resolved_patch_path)).resolve()
115+
if not patch_file.exists():
116+
raise FileNotFoundError(
117+
"resolved patch file does not exist in host repository: "
118+
f"{resolved_patch_path}"
119+
)
120+
121+
resolved_source_base_ref = source_base_ref_input
122+
if not resolved_source_base_ref:
123+
resolved_source_base_ref = (
124+
str(source_entry_raw.get("ref", "")).strip() or "main"
125+
)
126+
127+
result = {
128+
"source_path": resolved_source_path,
129+
"repo_url": resolved_repo_url,
130+
"source_repository": resolved_source_repository,
131+
"patch_path": resolved_patch_path,
132+
"source_base_ref": resolved_source_base_ref,
133+
}
134+
return result
135+
136+
137+
def write_github_output(outputs: dict[str, str], output_path: pathlib.Path) -> None:
138+
"""Write key-value pairs to the GitHub Actions output file."""
139+
with output_path.open("a", encoding="utf-8") as handle:
140+
for key, value in outputs.items():
141+
handle.write(f"{key}={value}\n")
142+
143+
144+
def main() -> int:
145+
"""Resolve metadata from workflow environment and emit GitHub outputs."""
146+
lockfile = pathlib.Path(os.environ.get("LOCKFILE_PATH", ".syncweaver-lock.json"))
147+
host_cwd = pathlib.Path(os.environ.get("SYNCWEAVER_HOST_CWD", ".")).resolve()
148+
source_path = os.environ.get("SOURCE_PATH_INPUT", "")
149+
repo_url = os.environ.get("REPO_URL_INPUT", "")
150+
source_repository = os.environ.get("SOURCE_REPOSITORY_INPUT", "")
151+
patch_path = os.environ.get("PATCH_PATH_INPUT", "")
152+
source_base_ref = os.environ.get("SOURCE_BASE_REF_INPUT", "")
153+
output_file = os.environ.get("GITHUB_OUTPUT", "")
154+
155+
if not output_file:
156+
raise RuntimeError("GITHUB_OUTPUT is not set")
157+
158+
resolved = resolve_contribute_patch_metadata(
159+
lockfile=lockfile,
160+
host_cwd=host_cwd,
161+
source_path=source_path,
162+
repo_url=repo_url,
163+
source_repository=source_repository,
164+
patch_path=patch_path,
165+
source_base_ref=source_base_ref,
166+
)
167+
write_github_output(resolved, pathlib.Path(output_file))
168+
return 0
169+
170+
171+
if __name__ == "__main__":
172+
raise SystemExit(main())

0 commit comments

Comments
 (0)