Keep shared files in sync across many repositories through pull requests, without overwriting what a team has made its own.
touchmark is the engine behind engineering-assets hubs. A hub is a repository your organisation creates from the hub template: it holds packs of shared files (agent instructions, review prompts, merge request templates, shared CI files) and a list of targets. Whenever a pack changes, touchmark opens a pull request (a merge request on GitLab) in every target that has opted in, on GitHub, GitLab, Gitea and Forgejo. It manages a file only while it can prove the hub shipped that exact content, so a line a team changes stays changed.
Three ways in, one version number:
| GitHub Action | uses: bedrock-python/touchmark@<commit> # v0.6.0 |
The hub's workflows on GitHub. Runs the release's image by digest, after checking its build provenance. The Action |
| Container image | ghcr.io/bedrock-python/touchmark:0.6.0 |
GitLab CI, Gitea and Forgejo Actions, docker run: touchmark and git on Alpine, as a non-root user. Run it in CI |
| Binary | Releases | Linux, macOS and Windows on amd64 and arm64, one static binary; or go install github.com/bedrock-python/touchmark/cmd/touchmark@latest with Go 1.26 or newer. Installation |
Tip
Setting up a hub with an AI assistant? Hand it one page instead of the whole site: every command and configuration file, the rules that break a hub when they are broken, the mistakes models make, and a map of which page to fetch for the rest. Every docs page is also served as raw Markdown at its own URL, and a Copy page button at the top of each one hands it straight to a chat window.
- Ownership from history — a file is managed only while its content is, byte for byte, a version the hub has shipped at that path. Change one line and the file is the repository's own: touchmark never overwrites or deletes it again
- No state in your repositories — no lock files, markers or generated headers. Ownership comes from the hub's git history, the memory of declined changes from the pull requests
- Review, never merge — every change arrives as a pull request a person merges; there is no auto-merge option
- Opt-in — a repository receives nothing until it adds
.engineering-assets.yml, which can add packs andignorepaths; a hub that owns the decision subscribes repositories itself (opt_in: assumed), andenabled: falsein the file still opts one out - Targets as you name them — a repository, an organisation or a group by path or by
web URL, narrowed by topics and path globs (
acme/svc-*), minusexcludeglobs (corp:platform/legacy/**) - Seven platforms, one hub — GitHub (github.com, GHE.com, Enterprise Server), GitLab (gitlab.com and self-managed), Gitea, Forgejo, Bitbucket Cloud, Azure DevOps Services and Bitbucket Data Center (the last three built from their documentation, not yet run on a live account); one hub can deliver to several at once, and its own CI can be any of them but Bitbucket Data Center, which has none
- Declines remembered — content in a pull request closed without merging is not proposed again; reopen it, or tick a box in its description, to change your mind
- A plan before delivery —
planin a hub pull request reports what every target would receive and how many API writes it costs, and changes nothing - Separate read and write accounts — the writer delivers from the hub's default branch
only;
setuparranges that on GitHub and GitLab,probeanddoctorcheck it - Runs nothing from your repositories — git with an argument list and no shell; targets are never checked out, so their hooks, filters, LFS and submodules never run
- Checkout line endings do not matter — a file's identity is its committed git blob id,
so a Windows checkout with
core.autocrlf=truebehaves like a Linux one - Built for CI — text, JSON and Markdown reports, step summaries and annotations, one comment on the hub pull request kept up to date (GitHub, Gitea, Forgejo), exit codes a pipeline can read
- Signed and attested releases — cosign signatures, SBOMs and GitHub build provenance for the binaries and the image
# The binary for your platform, from the release
v=0.6.0
curl -fsSLO "https://github.com/bedrock-python/touchmark/releases/download/v$v/touchmark_${v}_linux_amd64.tar.gz"
tar -xzf "touchmark_${v}_linux_amd64.tar.gz" touchmark
# With Go
go install github.com/bedrock-python/touchmark/cmd/touchmark@latest
# The image
docker pull ghcr.io/bedrock-python/touchmark:0.6.0Requirements: git 2.31+ for check, status, apply and manifest, git 2.45+ for
plan and distribute, which read the targets (the image brings its own); Go 1.26+ to
build from source. To check an archive or the image before you
run it, see Verifying what you run.
Most people never run touchmark by hand: they create a hub from the template, and the hub's CI runs it. Getting started walks through that on GitHub, GitLab and Gitea or Forgejo.
To see what it does, make a hub with one pack and one target:
mkdir -p hub/packs/agents && cd hub && git init -q
cat > hub.yml <<'EOF'
version: 1
id: acme-eng
EOF
cat > targets.yml <<'EOF'
version: 1
targets:
- repo: acme/billing
packs: [agents]
EOF
cat > packs/agents/AGENTS.md <<'EOF'
# Agent instructions
Run `make check` before you commit. Never commit secrets or generated files.
EOF
git add -A && git commit -qm "feat: the agents pack"
touchmark check --hub .Then a target that opts in, and the pack applied to it:
cd .. && git init -q billing && cd billing
touch .engineering-assets.yml # the repository's consent
touchmark apply --hub ../hub --repo acme/billingtouchmark apply · hub acme-eng @ afd206a · target acme/billing
packs: agents (targets.yml)
done create missing AGENTS.md agents
missing 1
applied 1 of 1 change
Change the file, and it is no longer the hub's:
echo "Ask before you add a dependency." >> AGENTS.md
touchmark status --hub ../hub --repo acme/billingtouchmark status · hub acme-eng @ afd206a · target acme/billing
packs: agents (targets.yml)
keep local AGENTS.md agents run touchmark apply --adopt AGENTS.md to take it back, or add it to ignore
local 1
in sync
In the hub's CI, plan and distribute make the same decisions for every target through
the platform's API, and each change arrives as a pull request. On GitHub the hub's workflow
runs the Action; its plan step on a hub pull request:
- uses: bedrock-python/touchmark@<commit> # v0.6.0
with:
command: plan
strict: true
comment: true
env:
GITHUB_TOKEN: ${{ github.token }}
TOUCHMARK_READ_APP_ID: ${{ vars.TOUCHMARK_READ_APP_ID }}
TOUCHMARK_READ_APP_KEY: ${{ secrets.TOUCHMARK_READ_APP_KEY }}For every path the selected packs have ever shipped, touchmark compares the target's file with the hub's history:
| State | The target's file… | touchmark… |
|---|---|---|
missing |
does not exist | creates it |
current |
matches the current pack version | leaves it |
outdated |
matches an older pack version | updates it |
local |
matches no version the hub ever shipped | leaves it: it is the repository's own |
ignored |
is listed under ignore |
leaves it |
retired |
is no longer shipped, and its content came from the hub | deletes it |
retired-local |
is no longer shipped, and its content is local | leaves it |
unsafe |
is a symlink, not a regular file, or points outside the repository | leaves it |
orphaned |
was shipped only by a pack this repository no longer gets | leaves it, and lists it |
Content under 64 bytes never counts as evidence, so an empty .gitkeep or a {} a team
wrote is never mistaken for a shipped file. The hub's full history is the ownership record:
touchmark refuses a shallow or partial clone of it.
Ownership by provenance has
the rest.
| Where | Command | What it does |
|---|---|---|
| The hub | check |
validates hub.yml, targets.yml and every pack |
plan |
reports what distribute would do in every target; changes nothing |
|
distribute |
opens, updates and closes the sync pull request of every target | |
doctor |
checks the write account against every target, and with --hub-token where the hub keeps its write key |
|
probe |
fails when the job can see the write key | |
setup github|gitlab |
sets up the hub's platform: the reader and the writer, the write key kept to the default branch | |
migrate |
prints a hub's configuration for one that replaces a multi-gitter setup on GitLab | |
manifest |
prints the ownership manifest built from the hub's history | |
| A target checkout | status |
lists every managed path and its state; changes nothing |
apply |
applies the packs to the working tree (--dry-run, --adopt GLOB) |
|
| Anywhere | schema NAME |
prints the JSON Schema of a configuration file or a report |
version |
prints the version |
Exit codes: 0 success, 1 an action failed, 2 a usage, configuration or safety error with
nothing written, 3 with --strict a blocked or deferred target, an incomplete resolve or a
check that warns (and from setup, a step left to you). Commands has
every flag.
| File | Lives in | What it holds |
|---|---|---|
hub.yml |
the hub | the hub's id, the platforms it delivers to with their writers, commit and pull request settings, security settings, pack descriptions, requires and formerly |
targets.yml |
the hub | which repositories get which packs: by repository (its path or web URL), organisation or group, filtered by topic and path pattern, minus exclude patterns; and whether the hub subscribes them without an opt-in file (opt_in: assumed) |
.touchmark/operations.yml |
the hub | one-off overrides of a safeguard, merged through review: rebuild a paused branch, propose declined content again, allow a mass close |
.engineering-assets.yml |
each target | the repository's consent, or with enabled: false its refusal; packs to add and paths to ignore |
Credentials come from the environment, never from flags or files:
TOUCHMARK_[<ID>_]READ_TOKEN (or …_READ_APP_ID with …_READ_APP_KEY for a GitHub App),
the same for WRITE, and an optional …_SIGNING_KEY. touchmark schema hub (targets,
opt-in, operations) prints the JSON Schema an editor completes from. The
Reference lists every key and
Environment variables
every name.
A hub can open pull requests in every repository it targets, and a sync pull request runs the target's CI before anyone reviews it. touchmark keeps that boundary, and fails closed where it is missing:
- Three accounts — a reader plans on hub pull requests; a writer, a different account, delivers from the hub's default branch only; the CI's own token reaches only the hub. The writer has no access to the hub, so a leaked write key cannot change the packs
- The write key stays on the default branch — a probe in the jobs any hub branch can
start fails if it sees the key, and
distributerefuses to run unless the platform keeps the key to the default branch orhub.ymlaccepts the risk with a reason - Overrides go through review — rebuilding a branch someone pushed to, proposing
declined content again and mass closes come only from
.touchmark/operations.ymlon the default branch - It touches only its own pull requests — in the target itself, by the writer or a known author, with the hub's fingerprint in the marker; a branch someone else's pull request uses is never moved
- Tokens stay out of sight — never in a URL, an argument or
.git/config, masked in every output; a secret found in a pull request body, comment, commit or branch name stops the operation
Security model explains each control, and the threat model maps every threat to its control, its test and the risk that remains. Report a vulnerability privately: see SECURITY.md.
bedrock-python.github.io/touchmark
- Getting started — install, a hub on GitHub, GitLab, Gitea or Forgejo, opting a repository in
- Concepts — how it works: ownership by provenance, packs and opt-in, delivery and the sync branch, the memory of declined pull requests, the security model
- How-to guides — set up a hub's platform, write packs, run it in CI, deliver to several platforms, one-off operations, check the write account, use it on your machine, migrate from multi-gitter, troubleshooting, FAQ
- Reference — every command and flag, every key of every configuration file, environment variables, exit codes, reports, the GitHub Action
- Project — the threat model, how releases are built and verified, the end-to-end tests against live platforms
- For AI agents — every command, the configuration, the rules that break a hub when broken and a map of the rest, on one page to hand to a coding assistant
make install # go mod download + uv sync --group docs
make check # gofmt + go vet + golangci-lint + govulncheck
make test-unit # go test ./..., no Docker required
make test # all tests with the race detector and the coverage gate
make test-e2e # the live tests against Gitea, in Docker
make build # bin/touchmark
make snapshot # a release build in dist/ with goreleaser; publishes nothing
make docs-serve # local docs previewSee CONTRIBUTING.md.
Apache 2.0 — see LICENSE.