All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
pull_requests.mirror_objectscould not work against GitHub, GitLab, or Bitbucket. The engine handed the destination side the rendered repository name, which is owner relative because the clone URL and repository creation prepend the owner themselves, while every destination pull request and comment endpoint interpolates that value as a fully qualified path: a GitHub destination asked for/repos/widget/pullsrather than/repos/acme/widget/pullsand every call answered 404. Azure DevOps failed the other way, duplicating the project into a path that already carried it, so listing pull requests 404d for every repository- Mirroring several repositories at once could abort the process. Pull request records were written into the shared git state from inside the engine's worker pool without the lock the engine uses for its own access, and two repositories finishing together is a concurrent map write, which Go turns into an unrecoverable crash. The state type now guards its own maps
- A private Docker Hub repository with
watch_tagson failed with a 404 blamed on the image. The login response's session token was discarded, so every listing fell back to HTTP basic auth, which that API does not accept - A destination pull request stayed open forever once its source merged or closed. Sources are listed open only, so a finished pull request stops appearing rather than arriving with a final state, and nothing closed the mirror; the branch under it was pruned by the mirror push meanwhile. Records the source no longer lists are reconciled and closed with a note
- Every pull request carrying a mirrored inline comment failed its conversation sync after the first run. GitHub keeps discussion and review comments in separate id spaces reached through different routes, and both were addressed through the discussion route
- A conversation that failed part way discarded the ids of the comments it had already posted, so the next run reposted all of them, forever. A repository whose pull request pass failed was also recorded as fully mirrored, so it was skipped on the next run and never retried until an unrelated branch or tag moved
- A single failed fetch of a pull request head deleted that branch from the destination, pulling the head out from under a mirrored pull request. The copy from the previous run is kept
- The image sync cron could overlap itself, corrupting the run report and racing on the sync state map. Both cron loops now skip a tick rather than start a second run, validate the schedule before the first run instead of after it, and install the signal handler before the first run so a shutdown during it can still persist state
- A malformed
excludeglob was ignored entirely, mirroring a repository that the rule was written to keep out. Filter patterns are validated at config load - A Slack notification could be dropped whole. One oversized failure line, such as a transport error carrying a large HTML body, produced a block that was empty or over the size limit, and Slack rejects both
- A failed state save on the fail-fast path was silent, so everything copied before the abort was copied again on the next run with no explanation
git-synccould not authenticate to Azure DevOps over git inpatmode, fixed in v0.2.1 and described there
- A mirrored pull request with no labels failed to update, taking the whole pull request with it. Once the mirror owned labels, an empty set was sent as null rather than as an empty array, and GitHub rejects that with 422. Most pull requests carry no labels, so this affected most of them. Found by the live suite on its first real run
- A live provider test suite behind the
livebuild tag, run withmake test-live. It creates two throwaway repositories against a real GitHub account, mirrors a pull request between them, and checks what actually landed, then deletes them. Every other test in the repository checks SyncerD against fakes written alongside the code, which is how three defects that made pull request mirroring inoperable passed a green suite and a review
- Cleared two advisories in transitive Go modules, an infinite loop in
golang.org/x/textand an out of bounds read inklauspost/compress. Neither was reachable from SyncerD's own code, but both had fixes and a later change could have started calling them - Patched a high severity advisory in
brace-expansion, which the Azure DevOps extension pinned to exactly the affected version through anoverridesentry
git-synccan recreate pull requests at the destination. Withpull_requests.mirror_objectson a mirror, each open source pull request becomes a real destination pull request, updated on every run, carrying its discussion comments, inline review comments anchored to the diff, review verdicts, and labels. A merged source pull request closes the destination one with a comment naming the source merge commit rather than merging it, because a destination merge would create a different commit that the branch mirror then overwrites. Review verdicts are mirrored as attributed text, never as real approvals, so a mirror cannot satisfy branch protection that no human satisfied. Mentions and issue references in mirrored text are neutralised, so mirrored content never notifies a destination account or cross links an unrelated issue. Everything SyncerD writes carries a hidden marker, so a lost state file causes re-inspection rather than duplicate pull requests, and only comments SyncerD wrote are ever edited or deleted. Withmirror_objectson, every mirrored pull request gets abranch_prefixbranch rather than fork heads alone, so a destination pull request has one uniform head name. Every provider type is supported, see below- Pull request mirroring now works for every provider type, in any direction: GitLab, Bitbucket Cloud, Azure DevOps, and AWS CodeCommit join GitHub. Each maps onto the same model, and the runbook carries a table of what each provider cannot represent
- CodeCommit is in scope after all. It was excluded when the work was planned on the grounds that it had no pull request API worth mirroring, which was wrong: it has a full one, already present in the pinned SDK, and AWS returned the service to general availability on 2025-11-24 after closing it to new customers in 2024
git-synccould not authenticate to Azure DevOps over git inpatmode. The provider presented an empty username with the PAT as the password, and SyncerD hands git its credential through a helper that printsusername=<value>: git reads an empty value as no username at all, tries to ask for one, and fails withcould not read Usernamebecause terminal prompts are disabled. Azure DevOps ignores the username and authenticates on the token alone, so the organisation name is sent now. A basic credential carrying a password with no username is also rejected up front, where the cause can be named, rather than surfacing as a bare git error. The REST API path was never affected: it sends the token in an Authorization header- A destination that cannot reopen a pull request, which is Bitbucket Cloud and CodeCommit, failed on every run once its mirror had been closed by hand. Having decided to leave the pull request closed, the engine still attempted an update on it, which both providers reject, so the failure was recorded before the divergence was: the warning fired every run, a failure was reported every run, and the mirror never settled. The same path ran for a merged source whose destination was already closed whenever new activity advanced its timestamp. An update now runs only while the destination is open, and a recorded divergence is re-checked each run so a destination somebody reopened by hand is picked back up
- A CodeCommit source served the first run's snapshot forever. Its pull request cache was documented as per run but a provider is built once and reused for every tick of the schedule, so later pull requests were never mirrored, closed ones never closed, and the repository was skipped as unchanged because the same stale set fed the ref fingerprint
- An Azure DevOps source mirrored each pull request exactly once and then froze it. Azure reports no update timestamp, and standing the creation date in for one meant the engine's watermark always matched, so no later edit, comment, or verdict reached the destination. A source that reports no timestamp is no longer watermarked
- CodeCommit reported "no reviews" when the underlying call failed, and the caller deletes mirrored comments it no longer sees at the source, so a single throttled call deleted every mirrored review verdict. It surfaces the error instead. For the same reason, a transient failure posting an anchored comment is no longer treated as a rejected anchor, which would have downgraded it to a plain comment permanently
- CodeCommit could not find a destination pull request that had been closed, so a lost state file created a second one for the same branch. GitLab anchored every comment on a deleted line to line 0 on the wrong side of the diff, and read its transient
lockedstate as closed, which closed the destination and posted "closed without merging" moments before the source became merged. Bitbucket dropped the draft flag on update and could not find a superseded pull request. GitHub reported no base SHA, so every inline comment mirrored to GitLab or CodeCommit, which both require one, silently became a plain comment. Azure DevOps counted a description limit in bytes rather than characters, truncating non-ASCII text to roughly a third of the allowed length - Turning
commentsorreviewsoff deleted everything previously mirrored in that category. Deletion is now scoped to the categories a run actually listed
vcs.PullRequestSpeccarriesSyncLabels, so a provider can tell "the source has no labels", where a destination label should be removed, from "label mirroring is off", where a label somebody added at the destination must be left alone. Labels are now reconciled rather than only added: one removed at the source is removed at the destinationvcs.PullRequestWritercloses a pull request but no longer reopens one. Bitbucket Cloud has no reopen endpoint and CodeCommit permits only theOPENtoCLOSEDtransition, so reopening is now an optionalvcs.PullRequestReopenercapability. A destination that lacks it leaves the pull request closed, warns once, and records the divergence rather than opening a second pull request for the same work or retrying on every runvcs.ReviewCommentcarriesBaseSHAalongsideCommitSHA: GitLab refuses to anchor an inline comment without base, head, and start SHAs, and CodeCommit needs a before and after commit id. A source that cannot supply them downgrades the comment to a discussion comment rather than dropping it
git-synccan mirror the commits behind open pull requests. A pull request opened from a fork has no branch in the source repository, so its commits previously reached no destination at all; withpull_requests.enabledon a mirror, the head is pushed as an ordinary branch underbranch_prefix(defaultsyncerd/pr/<number>), and the default mirror push mode prunes it once the pull request closes. A head that lives in the source repository already arrives with the ordinary branch mirror and gets no second copy. A repository whose own branches sit under the prefix is refused rather than silently overwritten, and one unreachable fork head is a warning rather than a failed repository. Off by default: a fork head is third party code, and a destination that builds on branch push would run it. GitHub sources only for now; the pull request objects themselves are not recreated at the destination yet
git-syncdiscovered only the public repositories of a GitHub personal account. The listing fell back toGET /users/{owner}/reposwhen the owner was not an organisation, and that endpoint returns public repositories only, whatever token is presented, so private repositories were silently missing from every run with no error to point at. When the token belongs to the configured owner, SyncerD now lists throughGET /user/repos?visibility=all&affiliation=owner, which reports private repositories. Mirroring another account still sees its public repositories only, since no credential can see furthergit-syncfailed outright at thediscoverstage when a GitLab source was a user namespace rather than a group: onlyGET /groups/{owner}/projectswas tried, and it 404s for a personal namespace. A 404 on the first page now falls back toGET /users/{owner}/projects, which lists private projects the token can see
- A
git-syncmirror whose source discovery succeeds but reports no repositories at all now logs a warning naming the likely cause (a token that cannot read the source's private repositories), because an empty run was otherwise indistinguishable from a mirror that is up to date
- Native authentication for private Amazon ECR destinations: when a destination host matches
<account>.dkr.ecr.<region>.amazonaws.com, SyncerD callsecr:GetAuthorizationTokenthrough the standard AWS credential chain (environment variables, shared config, IRSA, instance role) instead of requiring adocker login. Tokens are cached per account and region and refetched before they expire, so a long-running sync no longer needs an external refresh loop, and a Kubernetes deployment using IRSA no longer needs adockerConfigSecretfor ECR. An existing docker credential entry for the same registry still wins, so setups that log in explicitly are unchanged. Public ECR (public.ecr.aws) is a different service and is not covered
- The GitHub Action could not authenticate to any destination registry that was logged into with
docker/login-actionoraws-actions/amazon-ecr-login: it runs as a Docker container action, which does not receive the runner's~/.docker/config.json, so every request failed with401 Unauthorized. ECR now works with AWS credentials alone, and the README documents theDOCKER_CONFIGworkspace path that makes the other registries' login steps visible to the container
--report <path>writes a machine readable JSON run summary after each run, one schema shared bysyncandgit-syncso a consumer does not need to know which command produced the file; written on failed runs too, since those are the ones worth inspecting, and a write failure never fails the run--log-format jsonopt in structured logging forsyncandgit-sync; text remains the default and reproduces the historicallog.Printfoutput byte for byte, since operators grep it. JSON carries per repository and per run values as real fields--metrics-file <path>writes Prometheus textfile collector metrics (syncerd_last_run_unixtime,syncerd_last_success_unixtime,syncerd_last_run_success,syncerd_last_run_duration_seconds,syncerd_last_run_items) after each run, for bothsyncandgit-sync, including failed runs;syncandgit-syncseries coexist in the same file. Agit-sync --dry-runwrites no metrics, since it created, pushed, and deleted nothing- GitHub Action: new
dry-run,report,metrics-file, andlog-formatinputs.dry-runis the safety guard for git mirroring, whose default push mode deletes destination refs absent at the source, and it was previously unreachable from a workflow (#38) - Azure DevOps extension: documented git mirroring, including how provider tokens are supplied through pipeline variables. No code change was needed, since the task's
commandinput already accepted any subcommand (#38)
- A run time failure (a bad config, an unreachable provider) now prints only the error, without cobra's flag usage dump that used to follow it; the error is routed through the same logger as every other line, so a
--log-format jsonrun stays parseable end to end. A flag parse error (a typo like--bogus-flag) is unaffected and still prints the usage block, since that failure never reaches the point where usage gets silenced
internal/state's atomic write no longer leaves a stray.tmpfile behind when the rename fails, the same fix already applied to the run report writer- The README's GitHub Action example for git mirroring omitted the
env:block carrying provider tokens, so anyone copying it gottoken is required(#38)
- New
syncerd git-syncsubcommand mirrors git repositories across GitHub, GitLab, Bitbucket, Azure DevOps, and AWS CodeCommit, in any direction. Repositories are discovered from the source provider and filtered by name glob, archived, and fork status; unchanged repositories are detected via a source ref fingerprint and skipped without cloning - CodeCommit's git transport needs static IAM HTTPS Git credentials (
git_username/git_password, orSYNCERD_GIT_<NAME>_GIT_USERNAME/SYNCERD_GIT_<NAME>_GIT_PASSWORD): SyncerD does not derive SigV4 git credentials, so IRSA and instance roles only cover the API (listing and creating repositories) - Azure DevOps supports an
entraauth mode where the operator supplies a Microsoft Entra ID access token through the sameSYNCERD_GIT_<NAME>_TOKENvariable used for a PAT; SyncerD never acquires one itself - Bitbucket support is Cloud only (
api_urlchanges the host but not the Cloud shaped request paths, so Bitbucket Data Center is not supported), and Bitbucket has no archived concept, soskip_archivedhas no effect on a Bitbucket source - Three push modes control how a mirror updates its destination:
mirror(default) replicates the source exactly, deleting destination branches and tags absent at source;additivenever deletes;fast-forwardrefuses any non fast-forward update and reports it as a failure - An adopt guard refuses to push to a destination that already has content and no prior mirror state, so a misconfigured mirror cannot silently overwrite existing work; set
adopt: trueon the mirror to opt in --dry-runreports what would be created, pushed, and pruned, per repository and per ref; it creates, pushes, and deletes nothing at the destination, though it does populate the local clone cache, sincegit push --dry-runneeds the objects available locally- Helm chart: opt-in
gitSyncCronJob (gitSync.enabled: falseby default) runs on its own schedule, separate from image sync, with its own persistence for the clone cache and mirror state - Docker image now installs
git, required forgit-sync; image sync behaviour is unchanged - GitHub Action: new
commandinput selectssyncorgit-sync(defaults tosync) - Helm chart:
secret.gitTokensandsecret.gitCredentialshold inline git provider tokens and CodeCommit static credentials, keyed by provider name; preferexistingSecretin production - Helm chart:
cronjob.enabled(defaulttrue) runs the image sync CronJob; setfalsefor a deployment that only wants git mirroring
- Docker image
ENTRYPOINTwas relative (./syncerd), so container actions running with--workdir /github/workspacefailed withexec: "./syncerd": stat ./syncerd: no such file or directory; now absolute (/app/syncerd) - The Action referenced image tag
v1, which was never published; pinned tov0, the floating major tag the release workflow actually publishes for the current 0.x line
- Slack notifications now render as branded Block Kit messages: a colored bar (green for new syncs, red for failures), an emoji header with the count, code-formatted image refs grouped per destination in detailed mode, and a small SyncerD logo footer reading "SyncerD — powered by CloudDrove" (#31)
make installstill produceddev (commit: unknown)because theinstalltarget ran a plaingo installwithout ldflags — now injects version/commit like the other build targets (#29)- Local build version string now uses the nearest tag without the
vprefix (e.g.0.0.9), matching the released binary format instead of the verbosegit describeoutput (#29)
syncerd -valways printeddev (commit: unknown)becausemake build,docker build, and the CI image were built with a plaingo buildthat never injectedmain.version/main.commit. Version and commit metadata are now injected via ldflags in the Makefile (git-derived), Dockerfile (VERSION/COMMITbuild args), and release workflow (git tag + SHA) (#29)
- Slack notifications never fired when only
slack.webhook_url(orSYNCERD_SLACK_WEBHOOK_URL) was set, becauseslack.enableddefaulted tofalseand the notifier was never constructed — no success or failure alerts were sent (#27)
- Slack is now opt-out: configuring a webhook URL enables notifications automatically. Set
slack.enabled: false(orSYNCERD_SLACK_ENABLED=false) to suppress - Bound the previously unbound
slack.enabled,slack.notify_on_new, andslack.notify_on_errorkeys to environment variables
- Helm:
values.schema.jsonso chart values are validated on install/template and Artifact Hub shows the "Values schema" badge - Helm release pipeline now signs the published OCI chart with keyless cosign (Sigstore OIDC), enabling the Artifact Hub "Signed" badge
- Resolved 38 vulnerabilities (1 critical, 14 high, 20 medium, 2 low, 1 unknown) reported by the Artifact Hub / Trivy image scan; 35 were Go
stdlibCVEs fixed by rebuilding with a newer Go toolchain github.com/docker/clibumpedv27.5.1→v29.6.1(CVE-2025-15558)golang.org/x/sysbumpedv0.15.0→v0.46.0(CVE-2026-39824)
- Go 1.23 bumped to Go 1.26 across
go.mod,Dockerfile, and CI workflows (ci.yml,release.yml,syncerd.yml) to pick up patched standard-library crypto/tls, crypto/x509, net, net/url, and net/http fixes
- Helm: docker config secret was mounted at
/root/.dockerwhich is inside/root(mode 700); non-root pod user (uid 1000) could not traverse the directory even withfsGroupset — mount path changed to/var/lib/syncerd/.dockerandDOCKER_CONFIGupdated to match
- Docker image is now built for
linux/amd64andlinux/arm64(multi-arch manifest); QEMU added to the release pipeline and--platform=$BUILDPLATFORMused in the builder stage for native-speed cross-compilation; GitHub Actions layer cache enabled
- Helm:
dockerConfigSecretwas incorrectly used asimagePullSecrets; separated into a dedicatedimagePullSecretsvalue for pulling the SyncerD image anddockerConfigSecretfor destination registry auth - Helm: set
DOCKER_CONFIG=/root/.dockerwhendockerConfigSecretis configured so credentials are found when the pod runs as non-root - Helm:
state_pathandSYNCERD_STATE_PATHare now empty whenpersistence.enabled: false, making stateless mode truly stateless (no file I/O) - Helm: removed unused
emptyDirdata volume and mount in stateless mode DockerHubRegistry.Authenticatewas a no-op; now validates credentials against the Docker Hub API at startupListTagspagination silently swallowed errors and ignored context cancellation; now returns errors and checksctx.Err()between pages; page size increased to 100ImageExistsmasked all errors as image-not-found; now only treats HTTP 404 as not-found and propagates all other errors- Removed deprecated
rand.Seedcall (global source is auto-seeded since Go 1.20) - Removed dead
getDestinationImageNameno-op switch; removed unusedGetDefaultConfigPathfunction --onceflag moved from root persistent flags to thesyncsubcommand where it belongsaction.yml: fixed--onceboolean flag passing to use--once=<value>syntax- GitHub Actions
syncerd.yml: removed invalidsecretscontext usage inifconditions; replaced withcontinue-on-error: trueon optional registry login steps
- Go 1.21 (EOL) bumped to Go 1.23 across
go.mod,Dockerfile, and CI workflows - Docker base image pinned from
alpine:latesttoalpine:3.21 - Fixed
go.sum*glob togo.sumin DockerfileCOPY
- Initial release of SyncerD
- Support for syncing images from Docker Hub to multiple registries:
- AWS ECR
- Azure ACR
- Google GCR
- GitHub Container Registry (GHCR)
- Automatic tag detection and monitoring
- Cron-based scheduling for periodic syncs (default: every 3 weeks)
- YAML-based configuration with environment variable support
- GitHub Actions workflow support
- CLI tool with
--onceflag for one-time syncs - Support for watching all tags or specific tags per image
- Authentication support for all registry types
- Multi-registry destination support
- Automatic version detection
- Configurable sync schedules
- Secure authentication methods
- Comprehensive logging