Skip to content

v1.0.0 - #4

Merged
pytooling-claude[bot] merged 19 commits into
mainfrom
dev
Sep 10, 2026
Merged

pytooling-claude[bot] merged 19 commits into
mainfrom
dev

Conversation

@pytooling-claude

@pytooling-claude pytooling-claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

The first release of pyTooling/SynchronizeForks — a reusable GitHub Action synchronizing the branches and tags
of 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:

    - name: 🔄 Synchronize forked repositories
      uses: pyTooling/SynchronizeForks@v1
      with:
        github-token: ${{ secrets.GH_TOKEN }}

    The token needs write access to the contents of every listed fork, so a workflow's automatic GITHUB_TOKEN isn't
    sufficient.

  • Configuration files, compatible with the inline script's format. .ALL.repos lists the upstream organisations;
    each one has a matching <organisation>.repos file listing its forks as
    <upstream>=<fork>:<branches>[:<tagPatterns>]. Comments (#) and empty lines are ignored. The fourth field is new
    and optional, so an existing repository migrates by replacing its two script steps — nothing in its *.repos files
    has to change.

  • Input parameters. github-token is the only required one.

    Parameter Default Purpose
    target-organisation ${{ github.repository_owner }} The namespace owning the forks — the constant each copy had to edit.
    directory '.' Where the configuration files live.
    index-file '.ALL.repos' The index file's name.
    force false Hard reset the fork's branch (gh 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 sync updates a branch; it can't create one — so a branch
    added 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: true the action creates it from the upstream
    repository's branch head, and the next run synchronizes it normally:

      📂 OSVVM/AXI4 ⇒ PLC2/OSVVM-AXI4
        🌱 dev — created from OSVVM/AXI4@a1b2c3d
    

    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 configuration
    file before enabling it. While disabled, a missing branch is a counted error saying exactly that.

  • Tags are synchronized too, which gh repo sync cannot do at all — a fork drifts behind its upstream in
    releases even while its branches are current. ghdl/ghdl has 46 tags; Paebbels/ghdl has 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:

    ghdl/ghdl=ghdl:master:v\d+\.\d+.*
    OSVVM/OSVVM=OSVVM:main,dev:nightly,v\d+\.\d+\.\d+
    antonblanchard/microwatt=microwatt::v\d+\.\d+
    

    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:

        ☢️ 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
    

    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, skipped and errors, plus a summary block and a
    Not synchronized: list of what failed — so a run over a large namespace says what went wrong without scrolling
    through 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

  • The <upstream> field of a configuration line is reported in the progress and error output. It should name the real
    upstream repository.
  • Values reach the shell through the environment rather than by string interpolation into the script.

Bug Fixes

Three defects of the inline script, each of which ended a run green:

  • A configuration file without a final newline lost its last repository. while read -r line returns non-zero on
    an unterminated last line, so the loop body never ran for it.
  • A missing <organisation>.repos file was silent. The organisation loop read from < <(cat "${file}"); cat
    wrote to stderr, the inner loop iterated zero times, and the error counter stayed at 0. A typo in .ALL.repos
    synchronized nothing and reported success. It's a counted error now.
  • A multi-line error annotation was truncated. A workflow command is a single line, so the output of a failed
    gh repo sync was 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 sync command.

Documentation

README.md walks through a consuming repository: its file layout, the complete Synchronize.yml, the .ALL.repos
entry point, one <organisation>.repos file with a worked example, a sample log, the symbol table, the parameter
tables, 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.yml runs the action against the fixtures in tests/ in dry-run mode — no token, no
repository 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.yml classifies a run, and a merge commit
on main is tagged with the release pull-request's title and re-run on that tag, where PublishReleaseNotes.yml
publishes this page. A local UpdateVersionBranch.yml then opens the pull-request that moves the v1 branch to this
release, rewriting the references that have to name the branch rather than main.

None of that changes the action: action.yml is 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 — a grep
with 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

claude-code and others added 6 commits September 10, 2026 06:17
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
@pytooling-claude pytooling-claude Bot added the Enhancement New feature or request label Sep 10, 2026
Paebbels and others added 2 commits September 10, 2026 23:15
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
claude-code and others added 4 commits September 10, 2026 21:43
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
'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
Paebbels and others added 2 commits September 11, 2026 00:08
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
claude-code and others added 3 commits September 10, 2026 22:52
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
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'
@pytooling-claude
pytooling-claude Bot merged commit 5603948 into main Sep 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants