Deliver to several platforms¶
One hub can deliver to GitHub, GitLab, Gitea and Forgejo at once, including
self-managed instances. Each platform is a provider in hub.yml, with its own reader
and writer; targets.yml names repositories by provider. Bitbucket Cloud is a provider
too, and can host the hub: see Bitbucket Cloud.
One provider: the shorthand¶
A hub that delivers only to the platform its CI runs on needs no list: writer and
sign at the top level of hub.yml describe that one provider. Its type and URL come
from the CI (GITHUB_SERVER_URL, CI_SERVER_URL); when you run touchmark yourself, from
the hub's origin remote, if that is github.com, a *.ghe.com host or gitlab.com.
For a self-managed instance, name it so that touchmark also knows it outside CI: set
platform and base_url next to writer, or list it under providers. The provider's
id is then the platform's type (github, gitlab, gitea, forgejo), and its
variables keep their short names (TOUCHMARK_READ_TOKEN; TOUCHMARK_GITLAB_READ_TOKEN
works too).
version: 1
id: acme-eng
platform: gitlab
base_url: https://gitlab.example.com
writer: touchmark-writer
Several providers¶
version: 1
id: acme-eng
providers:
- id: gh
type: github # github.com, GHE.com or GitHub Enterprise Server
url: https://github.com
writer: acme-assets-write[bot]
- id: corp
type: gitlab
url: https://gitlab.example.com # a subpath works: https://example.com/gitlab
ca_file: certs/corp-ca.pem # a file in the hub, for git and the HTTP client
writer: touchmark-writer
known_authors: [group_42_bot_8f3c]
automation_accounts: [stale-bot]
limits: { writes_per_minute: 120 }
- id: cb
type: forgejo
url: https://codeberg.org # required for gitea and forgejo
writer: acme-touchmark
idis howtargets.ymlnames the provider, and the<ID>of its variables: upper-cased, with-as_.api_urlis needed only when it cannot be derived:api.github.com,<url>/api/v3for GitHub Enterprise Server,/api/v4for GitLab,/api/v1for Gitea and Forgejo,https://api.bitbucket.org/2.0for Bitbucket Cloud.known_authorsare former writers whose pull requests stay the hub's own: a replaced App, a recreated group access token's bot, the account of a multi-gitter setup. touchmark never adds anyone there itself.automation_accountsare bots whose closing of a pull request is not a team's decision (stale bots): their closes are not declines.signandlimitsare per provider.- In a hub pull request,
providersandca_fileare read from the default branch: a pull request cannot point the read key at a new host. New or changed providers are planned without credentials until the change is merged.
Targets on several providers¶
# targets.yml
version: 1
defaults:
provider: gh # entries that name no provider
packs: [agents]
targets:
- repo: acme/billing # gh, the default
- repo: corp:platform/api # <provider>:<path>
- repo: https://gitlab.example.com/platform/web # corp: the URL is under its url
- provider: corp
group: platform # with its subgroups; subgroups: false turns them off
topics: [python]
- org: https://codeberg.org/acme # cb:acme
exclude:
- corp:platform/sandbox
- corp:platform/legacy/** # a subgroup and everything beneath it
A target's provider is, in order: the entry's provider, the <provider>: prefix,
defaults.provider, the only provider. When that leaves a choice, check fails.
A target written as a web URL takes the provider whose url it lies under, matched by
scheme, host, port and path (https://example.com/gitlab/platform/api is under a
provider at https://example.com/gitlab); exactly one must match. touchmark rewrites
it as <provider>:<path> before anything else reads it. See
targets.yml.
Everywhere a single repository is named — in output, in --only, in
.touchmark/operations.yml — it is <provider>:<path>. Inside a run, a repository is
identified by its host and immutable id, so one repository named by two entries, or by
two providers on the same host, is delivered to once, with the packs of every entry.
Credentials per provider¶
TOUCHMARK_GH_READ_APP_ID TOUCHMARK_GH_READ_APP_KEY
TOUCHMARK_GH_WRITE_APP_ID TOUCHMARK_GH_WRITE_APP_KEY
TOUCHMARK_CORP_READ_TOKEN TOUCHMARK_CORP_WRITE_TOKEN TOUCHMARK_CORP_SIGNING_KEY
TOUCHMARK_CB_READ_TOKEN TOUCHMARK_CB_WRITE_TOKEN
Keep every write key next to the others, in the protected environment (GitHub) or the
protected, environment-scoped variable (GitLab), and add each to the probe: check
fails when a GitHub workflow hands touchmark a write key the probe does not test.
Delivering to Gitea or Forgejo from GitHub or GitLab¶
Gitea and Forgejo Actions cannot keep a secret to the default branch. A hub whose CI runs on GitHub or GitLab can still deliver to them: add them as providers, and keep their write token in the GitHub environment or the GitLab protected variable, where the probe covers it.
Bitbucket Cloud¶
A provider of type bitbucket is Bitbucket Cloud at https://bitbucket.org (leave url
out). plan, distribute and doctor work on it from a hub on GitHub or GitLab, from
a hub on Bitbucket itself, whose CI is Bitbucket Pipelines (see
A hub on Bitbucket Cloud), or wherever you run
touchmark; setup has nothing for it yet. Bitbucket Data Center has a provider type of
its own (see Bitbucket Data Center).
# hub.yml
providers:
- id: bb
type: bitbucket
writer: "{3f2a8d4e-1b6c-4f0a-9e7d-5c2b1a0f9e8d}" # the writer account's UUID, in braces
Accounts. Make two bot accounts (Atlassian accounts that only touchmark uses), each
with an API token; app passwords are gone, and repository, project and workspace access
tokens are not supported as reader or writer, since they cannot tell who they are (a
hub on Bitbucket uses one for its own repository only, TOUCHMARK_PIPELINES_TOKEN). A token's scopes do not
narrow the repositories it reaches, the account's permissions do: that is why the writer
is an account of its own.
| Reader | Writer | |
|---|---|---|
| Variable | TOUCHMARK_BB_READ_TOKEN |
TOUCHMARK_BB_WRITE_TOKEN |
Scopes (all …:bitbucket) |
read:user, read:workspace, read:repository, read:pullrequest |
the reader's, plus write:repository and write:pullrequest; no admin or delete scope |
| Repositories | read access to the targets | write access to the targets only (directly, through a group or the project); not admin, and no access to the hub |
API tokens expire within a year, and Bitbucket's API shows neither their expiry nor
their scopes: doctor reports both as unknown, so keep a reminder to rotate them.
- Accounts are UUIDs. Bitbucket finds no account by its nickname, so
writer,known_authorsandautomation_accountsname accounts by their UUID in braces, asGET https://api.bitbucket.org/2.0/usershows it. - Targets.
orgis a workspace,repoisworkspace/repository, and web URLs such ashttps://bitbucket.org/acme/billingwork. Bitbucket has no topics:checkrefusestopicson a Bitbucket entry; select repositories withmatch(acme/svc-*) or list them withrepo. - No labels. Bitbucket pull requests have none:
pr.labelssets nothing there, and touchmark records no label as set. - Drafts are Bitbucket's own draft flag (
pr.draft). - Closing. touchmark closes its own pull request by declining it, after writing the close into its description in the same edit. Bitbucket can never reopen a declined pull request, nor change it: a pull request touchmark closed stays closed, and the next proposal is a new one.
- Declines are remembered without writing to the declined pull request: while a pull
request is open, its marker records the state of the opt-in file it is proposed under
(touchmark brings it up to date when the file changes), and a person's decline holds
while the opt-in file is parsed the same. touchmark leaves no comment on it; the report
says
declined. To have declined content proposed again, editpacksorignore, or add aforget_declinesentry, which acts while it is present on Bitbucket (see Memory of declined pull requests). - No tick boxes, no HTML. Rebuild this branch and Propose this content again rest
on HTML comments, which Bitbucket shows as text: descriptions there carry neither. A
paused pull request asks for a
recreateentry in.touchmark/operations.ymlinstead, and the footnote namesforget_declines. The files the repository made its own are a plain section, not a folded one. Keeppr.intro_filefree of HTML too: Bitbucket shows it as text. - Branch restrictions are readable with admin rights only, which the writer should
not have:
doctorshowsrulesas unknown, and a push a restriction refuses ("Permission denied to update branch") isblocked:rules:protected-branch. - The marker. Bitbucket shows HTML comments in descriptions as text, so the marker
is a Markdown link reference definition there, which renders as nothing:
[touchmark]: # "touchmark:v1 hub=… fp=… stream=sync key=… data=…", the same payload as the comment elsewhere. An edit on the website may add backslashes before punctuation and Windows line endings; touchmark reads the marker either way. - Commit author. Bitbucket links a commit to the account one of whose confirmed
addresses is the author's: touchmark authors the writer's commits with its account's
primary confirmed address, and with
<uuid>@touchmark.invalid(an address that can belong to no one) when the account has none. - Pace. Bitbucket allows an account about 1 000 API requests an hour, so touchmark
reads 15 times a minute by default, with two targets at once, and writes at most once a
second;
limitsoverrides it.
Azure DevOps¶
A provider of type azure-devops is one organization of Azure DevOps Services, and its
url names it: https://dev.azure.com/<organization> (one provider per organization;
the old https://<organization>.visualstudio.com form and Azure DevOps Server are not
supported). plan, distribute and doctor work on it from a hub on any platform, or
wherever you run touchmark, and from a hub in Azure Repos itself, whose CI is Azure
Pipelines (see A hub on Azure DevOps); setup has
nothing for it.
# hub.yml
providers:
- id: ado
type: azure-devops
url: https://dev.azure.com/acme
writer: 3f2a8d4e-1b6c-4f0a-9e7d-5c2b1a0f9e8d # the writer user's identity id
Accounts. Use two users that only touchmark uses, each with a personal access token (PAT) for this organization only: service principals of Microsoft Entra ID are not supported yet. Global PATs (for all organizations) stop working on 2026-12-01.
| Reader | Writer | |
|---|---|---|
| Variable | TOUCHMARK_ADO_READ_TOKEN |
TOUCHMARK_ADO_WRITE_TOKEN |
| Scopes | Code (read) | Code (read & write) |
| Repositories | read access to the targets | Contribute, Create branch and Contribute to pull requests on the targets only; no administration, and no access to the hub |
A PAT cannot read its own expiry or scopes: doctor reports both as unknown, so keep a
reminder to renew the tokens before they expire.
- Accounts are identity ids. Display names are neither unique nor stable, so
writer,known_authorsandautomation_accountsname users by their identity id, a GUID:GET https://dev.azure.com/<organization>/_apis/connectionDatawith the user's token shows it asauthenticatedUser.id. - Targets. A repository is
<project>/<repository>; web URLs such ashttps://dev.azure.com/acme/Billing/_git/apiwork.orgnames the whole organization (org: acme, or its URLhttps://dev.azure.com/acme); select a project's repositories withmatch(Billing/*). Azure DevOps has no topics:checkrefusestopicson its entries. Disabled repositories are skipped. Project and repository names with spaces can be reached throughorgonly:targets.ymlpaths take letters, digits,.,-and_. - The marker lives in a pull request property (
touchmark.marker), not in the description, which Azure DevOps limits to 4 000 characters: the description is short, its lists are cut to fit, and the marker still records every change. Editing the description in the web UI leaves the marker as it is, and a marker pasted into the description is ignored. The property is not protected, though: anyone who may contribute to pull requests in the project, project Readers included by default, may be able to write it through the API (see the threat model). touchmark writes the property right after it opens a pull request, trying a few times; should it still fail, it abandons the new pull request and opens another, so that none stays without its marker. - People's text in the description is theirs: a close or a refresh of the marker writes the property alone and sends no description, so a description people filled up to the limit never stops touchmark from closing its pull request.
- Labels are added by name, as
pr.labelssays. Drafts are Azure DevOps' own draft flag (pr.draft). - Closing. touchmark closes its own pull request by abandoning it, after it stored the closing marker. It never reactivates an abandoned pull request, nor changes it: a pull request touchmark closed stays closed, and the next proposal is a new one.
- Declines are remembered without writing to the abandoned pull request, as on
Bitbucket Cloud: while a pull request is open its marker records the state of the
opt-in file, and a person's decline holds while that file is parsed the same. touchmark
leaves no comment on it; the report says
declined. To have declined content proposed again, editpacksorignore, or add aforget_declinesentry, which acts while it is present (see Memory of declined pull requests). - Comments are threads touchmark closes at once, so that they never wait for a resolution.
- Branch policies refuse direct pushes to the branches they protect, and the writer
reads none upfront:
doctorshowsrulesas unknown; a push a policy refuses (TF402455) isblocked:rules:policy, one the writer lacks a permission for (TF401027)blocked:permission:push. The writer's permissions are read with the Has Permissions API when the token may use it; when it may not,doctorshowsaccessas unknown and the first push or pull request meets a missing permission. - Commit author. touchmark authors the writer's commits with its sign-in address when
Azure DevOps shows one, else with
<identity id>@touchmark.invalid. - Pace. Azure DevOps meters a user's load over five minutes and announces it in its
X-RateLimit-*headers, which pause the provider before it delays requests, and inRetry-Afteron requests it delayed, which pauses the provider for that long; touchmark reads 120 times a minute with four targets at once and writes at most 30 times a minute by default;limitsoverrides it. - The reader needs a token. Azure DevOps sends anonymous reads of files (the Trees API) and of pull request properties to its sign-in page even in public projects, so a plan without the reader's token fails on Azure DevOps targets.
- Cost. Listing a target's pull requests reads the marker of every open one and of the 60 newest abandoned and completed ones (touchmark remembers the 50 newest closed pull requests); older closed pull requests count as without a marker.
What only a live organization can confirm, and the driver assumes from the REST
reference: the size limit of pull request properties and whether they survive an
abandon; whether labels in a create request are applied (touchmark adds missing ones
after it); the error of a duplicate pull request (TF401179); whether closedBy is set
on abandoned pull requests; the order of pull request listings and where they cut
descriptions; the answer to a refused PAT (401, a sign-in redirect or a 203 page); the
exact texts of push refusals; the mode strings of the Trees API; how disabled and
renamed repositories answer; who can write pull request properties (whether project
Readers, through "Contribute to pull requests", can set or change touchmark.marker);
and whether Retry-After comes on answers that went through.
Bitbucket Data Center¶
A provider of type bitbucket-datacenter is one Bitbucket Data Center instance, 8.19 or
later, and its url is the instance's base URL, context path included
(https://git.example.com/bitbucket); the REST API is under it, at
<url>/rest/api/latest. plan, distribute and doctor work on it from a hub on GitHub
or GitLab, or wherever you run touchmark. Bitbucket Data Center has no CI of its own, so
a hub cannot live there, and setup has nothing for it.
# hub.yml
providers:
- id: bbdc
type: bitbucket-datacenter
url: https://git.example.com/bitbucket
writer: touchmark-writer # the writer user's slug
automation_accounts: [touchmark-reader] # the reader: see "The read key can decline"
Accounts. Use two users that only touchmark uses, each with a personal HTTP access
token. touchmark sends every token as Authorization: Bearer, to the API and to git, on
every version from 8.19. Project and repository access tokens work too, but they act as a
service user Bitbucket makes for the token: it has no email address, and its name shows
only in answers (doctor and the report name it).
| Reader | Writer | |
|---|---|---|
| Variable | TOUCHMARK_BBDC_READ_TOKEN |
TOUCHMARK_BBDC_WRITE_TOKEN |
| Token permissions | repository read | repository write; no admin |
| Repositories | read access to the targets | write access to the targets only (directly, through a group or the project); not admin, and no access to the hub |
Bitbucket's API does not tell which token a request came with: doctor reports the
token's expiry and permissions as unknown, so keep a reminder to rotate them.
- Accounts are user slugs.
writer,known_authorsandautomation_accountsname users by their slug, as their profile URL shows it (<url>/users/<slug>); touchmark keeps their numeric ids. - Targets.
orgis a project, by its key (org: ACME, or its URL<url>/projects/ACME);repois<project key>/<repository slug>, and web and clone URLs such as<url>/projects/ACME/repos/billingand<url>/scm/acme/billing.gitwork.topicsare repository labels: an entry withtopics: [python]takes the project's repositories that carry the labelpython. Archived repositories are skipped, and so are those whose default branch does not exist (as empty). A project key that lists no repository is looked up: an unknown or changed key fails the provider's resolve, so no stale pull request is swept on its account. Personal repositories (~userprojects) are not targets. A renamed repository changes its slug: updatetargets.yml, or select it throughorg. - No labels. Bitbucket Data Center has labels on repositories only, not on pull
requests:
pr.labelssets nothing there, and touchmark records no label as set. - Drafts are Bitbucket's own draft flag (
pr.draft); on an instance that turned drafts off, the pull request opens ready. - No default reviewers. Bitbucket adds a repository's default reviewers to pull requests made in its web interface, not to those made through the API: add reviewers with a merge check or by hand.
- Closing. touchmark closes its own pull request by declining it, after writing the close into its description. It never reopens or changes a declined pull request, though Bitbucket would let it: a pull request touchmark closed stays closed, and the next proposal is a new one. A person may reopen one; it is touchmark's open pull request again.
- Declines are remembered as on Bitbucket Cloud, without writing to the declined pull
request: while a pull request is open, its marker records the state of the opt-in file,
and a person's decline holds while that file is parsed the same.
forget_declinesacts while it is present (see Memory of declined pull requests). - Who declined. Bitbucket declines a pull request without activity for four weeks by
default, as its own system user: touchmark reads who declined a pull request from its
activities, and takes a decline by the system user, or by a service user, for an
automatic close, which holds the content back for
memory.auto_close_cooldownonly. Only a person's decline holds until the opt-in file changes. - The read key can decline. On Bitbucket Data Center a user who may only read a
repository may still decline its pull requests, and the reader's token is in every
branch of the hub: list the reader in
automation_accounts, so that a decline made with it is taken for an automatic close, not the team's decision.planwarns until it is there. - No tick boxes, no HTML. Bitbucket shows HTML in descriptions as text: the marker is
a Markdown link reference definition, descriptions carry no tick boxes, a paused pull
request asks for a
recreateentry in.touchmark/operations.yml, andpr.intro_fileshould hold no HTML, as on Bitbucket Cloud. A description holds 32 768 characters; touchmark keeps its own under 30 000. - Branch permissions are readable with repository admin rights only, which the writer
should not have:
doctorshowsrulesas unknown. A push a branch permission refuses isblocked:rules:pull-request-onlywhen the branch takes changes through pull requests only,blocked:rules:branch-permissionsfor another permission, andblocked:rules:pre-receive-hookfor a hook (Reject Force Push, Verify Committer, the Jira commit checker). - Commit author. touchmark authors the writer's commits with the user's email address,
which links them to the user, and with
<user id>@touchmark.invalidfor a service user, which has none. - Pace. Bitbucket's rate limit gives each user a bucket of 60 requests, refilled at 5
a second, on each node, and answers 429 past it: touchmark reads 120 times a minute with
four targets at once and writes at most twice a second by default;
limitsoverrides it.
What the driver assumes from the REST reference and Atlassian's documentation, without a
live instance: that X-AUSERNAME names the user of every authenticated answer (how
touchmark learns who a token acts as), and the names of project and repository token
users, and whether GET /users?filter= finds those users (without it, such a token
cannot tell who it is, and the provider stops); that an HTML comment in a description shows as text and a link reference
definition does not; whether a declined pull request can be edited; what
at with direction=OUTGOING selects in pull request listings; whether the dashboard
lists another user's pull requests to the reader (the sweep of stale pull requests;
without it the sweep is skipped as incomplete); who the activities name for an automatic
decline; the texts of push refusals other than pull-request-only; the headers of 429
answers; and what the file APIs answer for a symlink (one may read as a file whose
content is its target) and for a missing path.
Moving¶
- A target moves to another provider on the same host (a new App, a new group): add
the old writer to
known_authorsof the new provider, so its open pull request stays the hub's. - The hub moves to another repository or host: list the old fingerprint,
host/id, underprevious_fingerprints; the next write gives each marker the new one. - The hub changes its
id: the sync branch's name changes; put the old one inbranch_aliases. Open pull requests and the memory of declines stay, because the hub is known by its fingerprint, not itsid.