v1.0.0 - #4
Merged
Merged
v1.0.0#4
Conversation
Convert the inline shell script of 'Paebbels/SynchronizeForks' into a reusable composite action, so consuming repositories keep only their '*.repos' configuration files. The algorithm is unchanged in what it synchronizes, but it now reports what the original silently ignored: a missing '<organisation>.repos' file, a malformed repository line, and the last entry of a file without a final newline. Multi-line 'gh repo sync' output is escaped for error annotations, inputs reach the script through the environment rather than by string interpolation, and 'dry-run', 'force' and 'fail-on-error' are new parameters. 'Verification.yml' runs the action in dry-run mode against the fixtures in 'tests/' and asserts its output parameters. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
The '<upstream>' field of a configuration line is reported in the progress and the error output: it names the upstream in the error annotation of a failed 'gh repo sync' and in the 'Not synchronized' list of the summary, beside the fork and the branch. A line with an empty '<upstream>', '<fork>' or branch list is rejected as malformed, so the field can't silently be empty. The README explains the workflow, the '.ALL.repos' entry point and an '<organisation>.repos' file with a worked example, shows the resulting log, and lists the repositories using this action. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
The script comes from 'Paebbels/SynchronizeForks', whose header reads "Copyright (c) 2024-2026 Patrick Lehmann". That line is preserved, and the pyTooling Authors are added from 2026 on, in 'action.yml', 'LICENSE.md' and the two files added beside them. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
Add the 'SynchronizeForks' composite action
'gh repo sync' updates a branch, it can't create one, so a branch listed in an '<organisation>.repos' file that the fork doesn't have yet can never be synchronized. Before synchronizing, the action now checks whether the fork has the branch, and creates it from the upstream repository's branch head when it doesn't. The upstream's commit is addressable through the fork, because GitHub keeps a fork and its upstream in one object network, so creating the branch is a single API call - no clone, fetch or push. This makes the '<upstream>' field functional: it names the repository the new branch's head is read from. A branch that exists in neither repository, a failed creation, and a missing branch while 'create-missing-branches' is disabled are counted errors. 'created' is a new output parameter. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
The branch head is read from '<upstream>', so a stale or copy-pasted upstream in a configuration file would create the branch from the wrong repository. Off by default; a repository enables it once its file was checked. While disabled, a missing branch stays a counted error naming the parameter. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
Create a branch that is missing in the fork
'gh repo sync' knows branches only, so a fork drifts behind its upstream in releases even while its branches are current - 'ghdl/ghdl' has 46 tags, 'Paebbels/ghdl' has 19. A configuration line gains an optional fourth field, colon separated, listing tag names or regular expressions: '<upstream>=<fork>:<branches>[:<tagPatterns>]'. A line without it behaves exactly as before, and a line may carry tag patterns without any branch. Patterns are matched against the whole tag name with 'grep -P', joined into one alternation because GNU grep rejects several '-e' patterns together with '-P'. A matching tag missing from the fork is created pointing at the same object. A tag that moved in the upstream repository is reported as an error and left alone - rewriting it would discard whatever the fork's tag points at - and the run continues. Tags are never deleted. 'created-tags' is a new output parameter. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
Review feedback: 🏷️ meant both "a tag was created" and "here is some information about tags", and ❌ meant both "a branch failed to synchronize" and "a tag moved". 🏷️ now marks a created tag only. 🟰 reports the tags that already point at the same object as upstream, ℹ️ the two cases with nothing to do (the upstream has no tags, no tag matched), and ☢️ a tag that moved upstream - in the log and in the 'Not synchronized' list, which required each entry of that list to carry its own symbol instead of a hard-coded ❌. The README gains a table of the symbols. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
Review feedback: a moved tag reported two SHAs and nothing else, so a reader couldn't tell which of the two is
newer without looking both up by hand.
Both sides are resolved through 'GET /repos/<owner>/<repo>/commits/<tag>', which dereferences an annotated tag -
the tag ref's object SHA is the tag object there, and that endpoint rejects it. The report names the commit each
tag points at and that commit's date:
☢️ v1.0.0 — moved in 'OSVVM/OSVVM'
↪ fork: 90e6af7 2024-03-11 14:22:05 UTC
↪ upstream: d3d07ba 2025-07-02 09:41:18 UTC
The error annotation carries the full SHAs and both dates. When the two resolve to the same commit, the tag object
itself was recreated, and the report says so rather than leaving two identical lines unexplained. A lookup that
fails falls back to the tag refs without a date, so the moved tag is still reported.
The timestamp is reformatted with parameter expansion rather than 'date', which parses this input only in its GNU
flavour.
Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
Synchronize tags
'TagReleaseCommit.yml' dispatches 'Pipeline.yml' by default, so the workflow file has to carry that name; the rename is what makes the release chain work without overriding the template's 'workflow' input. Three reusable workflows from pyTooling/Actions@r8 are wired in: * 'PrepareJob.yml' classifies the run - branch or tag, regular or merge commit, release commit or release tag. * 'TagReleaseCommit.yml' tags a merge commit on 'main' with the release pull-request's title and dispatches this pipeline on the tag. A tag pushed with the automatic GITHUB_TOKEN doesn't trigger a workflow by itself, hence the explicit dispatch and the 'workflow_dispatch' trigger. * 'PublishReleaseNotes.yml' runs on the tagged pipeline, after the same three verification jobs pass, and publishes the release page. It resolves the pull-request from the merge commit's second parent itself, so the release description is the body of the release pull-request and needs no input here. The three verification jobs don't depend on 'Prepare', so they still start immediately; only the two release jobs wait for it. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
Rename the workflow to 'Pipeline.yml' and release from it
Consumers pin a major version ('@v1'), so every release has to move that branch. Beside the release page, the
tagged pipeline now opens a pull-request from 'main' to '<prefix><major>', titled 'Updating v1 from v1.0.1'.
'UpdateVersionBranch.yml' is a local reusable workflow, so it can be exercised here before being promoted to
pyTooling/Actions. Its inputs are the released version, the branch prefix ('v' or 'r') and the main branch; it
reports the branch, whether it created it, and the pull-request.
A major that has no branch yet is created from the highest lower major - 'v2' from 'v1' - not from 'main': a
branch cut from 'main' is already identical to it and there would be nothing to open a pull-request about. The
first release of all has no previous branch, so the branch is created at 'main' and no pull-request is opened.
A branch already level with 'main' is left alone, and a pull-request still open from the previous release is
retitled instead of duplicated.
The workflow also has a 'workflow_dispatch' trigger, so the whole path can be rehearsed before a release exists.
Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
A workflow, action or badge on 'v1' has to reference 'v1'. Merging 'main' into 'v1' carries '@main' along, so the version branch would run the main branch's code and show the main branch's badge. The workflow now scans the tracked '*.yml', '*.yaml' and '*.md' files for references to this repository - '<owner>/<repo>[/<path>]@<ref>' and the workflow-status badge's 'branch=' - and rewrites them to the version branch. Only self-references: 'pyTooling/Actions@r8' and 'actions/checkout@v7' are separate decisions and are left alone. Where a rewrite is needed it becomes a commit on '<update_branch_prefix><branch>', and the pull-request is opened from there; where nothing needs rewriting the pull-request stays a plain merge of the main branch. This replaces the amend + force-push of the version branch: the rewrite is a reviewable commit in the pull-request, and no consumable branch is force-pushed. The step is split in three - resolve the branch, rewrite, open the pull-request - with 'head' and 'rewritten' as two new output parameters. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
Propose the version branch update from the tagged pipeline
Paebbels
self-requested a review
September 10, 2026 23:01
Following 3504431: only the files carrying the lineage of the 2024 script keep "Copyright © 2024-2026 Patrick Lehmann". 'action.yml' is that script and 'LICENSE.md' is the repository's licence, so both keep it; 'Pipeline.yml' and the 'check-outputs' action are new work and now carry the pyTooling Authors alone, like 'UpdateVersionBranch.yml' already does. The README's usage example pins '@v1' and the note saying no version has been released is gone - it would have been false the moment the release it ships in is published. A sentence explaining the major-version branch and '@v1.0.0' for a single release takes its place. Co-Authored-By: Patrick Lehmann <Paebbels@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnpaXYDwfNwnJrQew78j3f
Correct the copyright headers and pin the examples to '@v1'
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The first release of
pyTooling/SynchronizeForks— a reusable GitHub Action synchronizing the branches and tagsof forked repositories of a GitHub organisation or user account with their upstream repositories.
Forks don't update themselves. GitHub offers a Sync fork button per repository and per branch, but no automation for
a whole namespace — and nothing at all for tags. This action reads a list of forks, their branches and their tag
patterns from simple configuration files and synchronizes them one by one — usually from a scheduled workflow running
once a day.
The algorithm was previously an inline shell script, copied into each repository's workflow. It now lives here, and a
consuming repository keeps only its configuration files.
New Features
The action. A consuming workflow is one step:
The token needs write access to the contents of every listed fork, so a workflow's automatic
GITHUB_TOKENisn'tsufficient.
Configuration files, compatible with the inline script's format.
.ALL.reposlists the upstream organisations;each one has a matching
<organisation>.reposfile listing its forks as<upstream>=<fork>:<branches>[:<tagPatterns>]. Comments (#) and empty lines are ignored. The fourth field is newand optional, so an existing repository migrates by replacing its two script steps — nothing in its
*.reposfileshas to change.
Input parameters.
github-tokenis the only required one.target-organisation${{ github.repository_owner }}directory'.'index-file'.ALL.repos'forcefalsegh repo sync --force).|
create-missing-branches|false| Create a listed branch the fork doesn't have yet. See below. ||
dry-run|false| Print the commands instead of running them; reads no repository. ||
fail-on-error|true| Report the counters without failing the job. |A branch the fork doesn't have can be created.
gh repo syncupdates a branch; it can't create one — so a branchadded upstream after the fork was made, or every branch but one when the fork was made with Copy the default branch
only, could never be synchronized. With
create-missing-branches: truethe action creates it from the upstreamrepository's branch head, and the next run synchronizes it normally:
It's a single API call — a fork and its upstream share one object network, so no clone, fetch or push is involved.
It's off by default, because the branch head is read from
<upstream>: check that field in your configurationfile before enabling it. While disabled, a missing branch is a counted error saying exactly that.
Tags are synchronized too, which
gh repo synccannot do at all — a fork drifts behind its upstream inreleases even while its branches are current.
ghdl/ghdlhas 46 tags;Paebbels/ghdlhas 19.The optional fourth field of a configuration line says which tags to follow, as fixed names or regular expressions
matched against the whole tag name:
A matching tag the fork doesn't have is created at the same object. One that already matches is counted. One that
moved upstream is reported and deliberately left alone — rewriting a tag discards whatever the fork's tag points
at, which isn't a decision an unattended nightly job should make. Both sides are resolved to the commit they point
at, so the report says which is older:
Tags are never deleted from a fork, and a tag existing only in the fork is left untouched. As the third line above
shows, a fork may be followed for its tags alone, with no branches.
Output parameters
synchronized,created,created-tags,skippedanderrors, plus a summary block and aNot synchronized:list of what failed — so a run over a large namespace says what went wrong without scrollingthrough the log.
A symbol per kind of event, documented in the README: 🏭 organisation, 📂 fork, ✅ branch synchronized, 🌱 branch
created, 🏷️ tag created, 🟰 tags already up to date, ☢️ tag moved, ℹ️ nothing to do, 🚫 commented out, 🚧 dry-run,
❌ error.
One collapsible group per organisation, and the upstream repository named in every progress line and every error
annotation.
Changes
<upstream>field of a configuration line is reported in the progress and error output. It should name the realupstream repository.
Bug Fixes
Three defects of the inline script, each of which ended a run green:
while read -r linereturns non-zero onan unterminated last line, so the loop body never ran for it.
<organisation>.reposfile was silent. The organisation loop read from< <(cat "${file}");catwrote to stderr, the inner loop iterated zero times, and the error counter stayed at
0. A typo in.ALL.repossynchronized nothing and reported success. It's a counted error now.
gh repo syncwas cut at its first newline. Newlines are escaped as%0A.Malformed lines — no
=, no:, or an empty<upstream>,<fork>and tag list — are counted errors as well,instead of producing a nonsensical
gh repo synccommand.Documentation
README.mdwalks through a consuming repository: its file layout, the completeSynchronize.yml, the.ALL.reposentry point, one
<organisation>.reposfile with a worked example, a sample log, the symbol table, the parametertables, the missing-branch and tag-synchronization behaviour, the error handling, how an existing repository migrates,
and the repositories using this action.
Unit Tests
.github/workflows/Verification.ymlruns the action against the fixtures intests/in dry-run mode — no token, norepository touched — and asserts all five output parameters for a valid configuration, for an invalid one, and that
counted errors make the step fail.
Others
The repository releases itself, with reusable workflows from
pyTooling/Actions
@r8:PrepareJob.ymlclassifies a run, and a merge commiton
mainis tagged with the release pull-request's title and re-run on that tag, wherePublishReleaseNotes.ymlpublishes this page. A local
UpdateVersionBranch.ymlthen opens the pull-request that moves thev1branch to thisrelease, rewriting the references that have to name the branch rather than
main.None of that changes the action:
action.ymlis byte-for-byte what #3 left behind.Related Issues and Pull-Requests
Conversions of the repositories that carried the inline script: Paebbels/Synchronize#3 and VHDL/Synchronize#1.
Runs on GitHub-hosted runners with no setup: it needs the GitHub CLI (
gh), and — for tag patterns only — agrepwith PCRE support (
-P).The algorithm comes from Paebbels/SynchronizeForks; its copyright is
carried over — Patrick Lehmann from 2024, The pyTooling Authors from 2026.
🤖 Generated with Claude Code