Skip to content

Commit 4d7be78

Browse files
chore(release): 0.2.1 (#47)
* chore(release): 0.2.1 Cuts the Unreleased section carrying destination pull request mirroring, the four remaining provider implementations, and the fixes from the review of #46, and repoints the Action examples at the new tag. Also corrects the 0.2.0 section, which claimed destination pull request mirroring. That entry was written under Unreleased on the #44 branch, and by the time it merged the release PR had already renamed that heading to 0.2.0, so a clean auto-merge filed a feature under a tag that never contained it. The 0.2.0 section now matches what v0.2.0 actually shipped. * fix(azuredevops): send a username with the PAT on the git transport pat mode presented an empty username with the PAT as the password. SyncerD hands git its credential through a helper that prints "username=<value>", and git reads an empty value as no username at all: it then tries to ask, terminal prompts are disabled, and every clone and push failed with "could not read Username", which names nothing about the provider or the config that produced it. Azure DevOps ignores the username on this path and authenticates on the token alone, so the value is free. The organisation name is sent because it makes the credential legible in a trace without being a secret. A basic credential carrying a password with no username is now rejected by the runner as well, so a future provider with the same gap fails with a sentence that names the cause rather than a bare git error. A bearer credential still needs no username, and an empty credential, which is what a local path uses, stays valid. The REST API path was never affected: it carries the token in an Authorization header, where an empty username is what Azure DevOps documents. --------- Co-authored-by: Anmol Nagpal <ianmolnagpal@gmail.com>
1 parent 2b9a50b commit 4d7be78

6 files changed

Lines changed: 80 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.1] - 2026-08-19
11+
1012
### Added
13+
- `git-sync` can recreate pull requests at the destination. With `pull_requests.mirror_objects` on 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. With `mirror_objects` on, every mirrored pull request gets a `branch_prefix` branch rather than fork heads alone, so a destination pull request has one uniform head name. Every provider type is supported, see below
1114
- 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
1215
- 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
1316

17+
### Fixed
18+
- `git-sync` could not authenticate to Azure DevOps over git in `pat` mode. The provider presented an empty username with the PAT as the password, and SyncerD hands git its credential through a helper that prints `username=<value>`: git reads an empty value as no username at all, tries to ask for one, and fails with `could not read Username` because 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
19+
- 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
20+
- 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
21+
- 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
22+
- 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
23+
- 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 `locked` state 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
24+
- Turning `comments` or `reviews` off deleted everything previously mirrored in that category. Deletion is now scoped to the categories a run actually listed
25+
1426
### Changed
1527
- `vcs.PullRequestSpec` carries `SyncLabels`, 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 destination
1628
- `vcs.PullRequestWriter` closes a pull request but no longer reopens one. Bitbucket Cloud has no reopen endpoint and CodeCommit permits only the `OPEN` to `CLOSED` transition, so reopening is now an optional `vcs.PullRequestReopener` capability. 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 run
@@ -19,7 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1931
## [0.2.0] - 2026-08-19
2032

2133
### Added
22-
- `git-sync` can recreate pull requests at the destination. With `pull_requests.mirror_objects` on 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. With `mirror_objects` on, every mirrored pull request gets a `branch_prefix` branch rather than fork heads alone, so a destination pull request has one uniform head name. GitHub source and destination for now; other providers are rejected at startup
2334
- `git-sync` can 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; with `pull_requests.enabled` on a mirror, the head is pushed as an ordinary branch under `branch_prefix` (default `syncerd/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
2435

2536
### Fixed

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Requires Go 1.23+ to build from source.
121121
Add SyncerD to your workflow:
122122

123123
```yaml
124-
- uses: clouddrove/syncerd@v0.2.0
124+
- uses: clouddrove/syncerd@v0.2.1
125125
with:
126126
config: syncerd.yaml
127127
once: "true"
@@ -139,7 +139,7 @@ For **AWS ECR**, add `aws-actions/configure-aws-credentials` before SyncerD and
139139
role-to-assume: arn:aws:iam::123456789012:role/syncerd
140140
aws-region: eu-west-1
141141
142-
- uses: clouddrove/syncerd@v0.2.0
142+
- uses: clouddrove/syncerd@v0.2.1
143143
with:
144144
config: syncerd.yaml
145145
```
@@ -157,7 +157,7 @@ steps:
157157
username: ${{ github.actor }}
158158
password: ${{ secrets.GITHUB_TOKEN }}
159159
160-
- uses: clouddrove/syncerd@v0.2.0
160+
- uses: clouddrove/syncerd@v0.2.1
161161
env:
162162
DOCKER_CONFIG: /github/workspace/.docker # same directory, path inside the container
163163
with:
@@ -181,7 +181,7 @@ steps:
181181
Since the default push mode deletes destination refs that are absent at the source, **run once with `dry-run: "true"` before trusting a new mirror config**:
182182
183183
```yaml
184-
- uses: clouddrove/syncerd@v0.2.0
184+
- uses: clouddrove/syncerd@v0.2.1
185185
with:
186186
command: git-sync
187187
config: syncerd.yaml
@@ -195,7 +195,7 @@ Since the default push mode deletes destination refs that are absent at the sour
195195
Once the dry run output looks right, drop `dry-run` (or set it to `"false"`) to let it write for real:
196196

197197
```yaml
198-
- uses: clouddrove/syncerd@v0.2.0
198+
- uses: clouddrove/syncerd@v0.2.1
199199
with:
200200
command: git-sync
201201
config: syncerd.yaml

internal/gitsync/runner.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,14 @@ func rejectURLCredentials(raw string) error {
334334
// newline silently truncates the value and turns the remainder into a
335335
// separate directive.
336336
func validateCredential(cred vcs.GitCredential) error {
337+
// A basic credential with a secret and no username cannot authenticate.
338+
// The helper would print "username=", git would read that as no
339+
// username, and with prompts disabled the operation fails with "could
340+
// not read Username", which says nothing about the provider that
341+
// produced it. Fail here instead, where the cause is nameable.
342+
if cred.Kind == vcs.CredBasic && cred.Secret != "" && cred.User == "" {
343+
return fmt.Errorf("provider supplied a password with no username; git cannot authenticate with an empty username")
344+
}
337345
if strings.ContainsAny(cred.Secret, "\n\r") {
338346
return fmt.Errorf("credential secret contains a newline, which git would read as a protocol directive")
339347
}

internal/gitsync/runner_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,3 +699,33 @@ func TestPrunePRBranchesOnACacheWithNoPRBranches(t *testing.T) {
699699
t.Errorf("deleted = %d, want 0", deleted)
700700
}
701701
}
702+
703+
func TestValidateCredentialRejectsAPasswordWithNoUsername(t *testing.T) {
704+
// This is what an Azure DevOps PAT credential used to look like. The
705+
// helper would print "username=", git would read that as no username,
706+
// and the clone would fail with "could not read Username", which names
707+
// nothing useful. Fail where the cause can be named instead.
708+
err := validateCredential(vcs.GitCredential{Kind: vcs.CredBasic, Secret: "a-token"})
709+
if err == nil {
710+
t.Fatal("a basic credential with no username cannot authenticate and must be rejected")
711+
}
712+
if !strings.Contains(err.Error(), "empty username") {
713+
t.Errorf("the error should name the cause, got %v", err)
714+
}
715+
}
716+
717+
func TestValidateCredentialAllowsNoCredentialAtAll(t *testing.T) {
718+
// A local path needs no credential; that is not the same as a password
719+
// with a missing username.
720+
if err := validateCredential(vcs.GitCredential{}); err != nil {
721+
t.Fatalf("an empty credential is valid: %v", err)
722+
}
723+
}
724+
725+
func TestValidateCredentialAllowsABearerWithoutAUsername(t *testing.T) {
726+
// Entra mode carries the token in an Authorization header, so there is
727+
// no username to supply.
728+
if err := validateCredential(vcs.GitCredential{Kind: vcs.CredBearer, Secret: "entra-token"}); err != nil {
729+
t.Fatalf("a bearer credential needs no username: %v", err)
730+
}
731+
}

internal/vcs/azuredevops/azuredevops.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,19 +141,29 @@ func (p *Provider) CloneURL(name string) string {
141141
// GitCredential returns the credential git should present, matching the
142142
// configured auth mode.
143143
//
144-
// pat mode returns HTTP basic auth with an empty username and the PAT as
145-
// the password, which is what Azure DevOps expects. entra mode returns a
146-
// bearer credential carrying the operator supplied Entra access token. If
147-
// entra mode is configured but no token was ever supplied, this returns an
148-
// error rather than presenting an empty bearer token to git.
144+
// pat mode returns HTTP basic auth with the PAT as the password. Azure
145+
// DevOps ignores the username on this path and authenticates on the token
146+
// alone, so the value is free; the organisation name is used because it
147+
// makes the credential legible without being a secret.
148+
//
149+
// It must not be empty, which is what it used to be. SyncerD hands git its
150+
// credential through a helper that prints "username=<value>", and git reads
151+
// an empty value as no username at all: it then tries to ask, terminal
152+
// prompts are disabled, and the clone fails with "could not read Username"
153+
// rather than anything pointing at the configuration.
154+
//
155+
// entra mode returns a bearer credential carrying the operator supplied
156+
// Entra access token. If entra mode is configured but no token was ever
157+
// supplied, this returns an error rather than presenting an empty bearer
158+
// token to git.
149159
func (p *Provider) GitCredential(context.Context) (vcs.GitCredential, error) {
150160
if p.auth == authEntra {
151161
if p.token == "" {
152162
return vcs.GitCredential{}, fmt.Errorf("azuredevops: entra mode requires a token supplied by the operator; none was configured")
153163
}
154164
return vcs.GitCredential{Kind: vcs.CredBearer, Secret: p.token}, nil
155165
}
156-
return vcs.GitCredential{Kind: vcs.CredBasic, User: "", Secret: p.token}, nil
166+
return vcs.GitCredential{Kind: vcs.CredBasic, User: p.org, Secret: p.token}, nil
157167
}
158168

159169
// apiRepo is the subset of the Azure DevOps repository object SyncerD

internal/vcs/azuredevops/azuredevops_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,15 @@ func TestPATModeGitCredential(t *testing.T) {
311311
if cred.Kind != vcs.CredBasic {
312312
t.Errorf("Kind = %v, want CredBasic", cred.Kind)
313313
}
314-
if cred.User != "" {
315-
t.Errorf("User = %q, want empty", cred.User)
314+
// Azure DevOps ignores the username and authenticates on the PAT alone,
315+
// but it cannot be empty: SyncerD's credential helper prints
316+
// "username=<value>", and git reads an empty value as no username,
317+
// then fails with "could not read Username" because prompts are off.
318+
if cred.User == "" {
319+
t.Error("User must not be empty; git cannot authenticate without one")
320+
}
321+
if cred.User != "acme-org" {
322+
t.Errorf("User = %q, want the organisation name", cred.User)
316323
}
317324
if cred.Secret != "pat-test-token" {
318325
t.Errorf("Secret = %q, want the PAT", cred.Secret)

0 commit comments

Comments
 (0)