Tangled Mirror #
Keep a Tangled repo in sync with one that lives on GitHub.
Tangled is a social coding platform built on the AT Protocol. If GitHub is your project's home and you want a copy visible on Tangled, this action handles the push. It checks the knot's host key, sends only the refs you ask for, and won't delete anything unless you tell it to.
Quick start #
name: Mirror to Tangled
on:
push:
branches: [main]
tags: ['v*'] # a branch push does not fire on tag pushes
workflow_dispatch:
permissions:
contents: read
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
ref: main # tag pushes would land on a detached HEAD
fetch-depth: 0 # required, knots reject shallow pushes
- uses: sethcottle/tangled-mirror@v1
with:
repo: your-handle.bsky.social/your-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.TANGLED_KNOWN_HOSTS }}
Setup #
1. Create the repo on Tangled #
The action pushes to a repo that already exists and won't create one for you, so
make it first. Copy the SSH remote Tangled gives you. Everything after the colon
is the value for the repo input.
Tangled's web UI shows repo paths with a leading @, as in
@seth.bsky.social/my-repo, but the SSH remote drops it. The action strips a
leading @ if you paste one, so either form works.
2. Make an SSH key just for CI #
Generate a separate key rather than reusing the one on your laptop, so you can revoke it on its own later.
ssh-keygen -t ed25519 -f ./tangled-ci -C "tangled-ci" -N ""
You'll get two files: tangled-ci (private) and tangled-ci.pub (public).
3. Register the public half with Tangled #
Paste the contents of tangled-ci.pub at https://tangled.org/settings/keys.
Tangled doesn't have per-repository deploy keys the way GitHub does. Keys are registered against your account, so this key can push to any repo you own on that knot, and there's no way to narrow its scope. This is a Tangled limitation rather than something the action can work around, but you should know about it before putting a key into CI.
4. Store the private half as a GitHub secret #
If you have the gh CLI, this is one command from your repo root:
gh secret set TANGLED_SSH_KEY < ./tangled-ci
Otherwise, go to Settings, then Secrets and variables, then Actions, and add a
repository secret named TANGLED_SSH_KEY containing the contents of
tangled-ci, including the BEGIN and END lines.
Once GitHub has it you can remove your local copy with
rm tangled-ci tangled-ci.pub.
5. Pin the knot's host key #
Without this, every run trusts whatever host answers at tangled.org. Run the
following from a machine you trust:
ssh-keyscan -t ed25519 tangled.org
Copy the line it prints. Back in Settings, Secrets and variables, Actions,
switch to the Variables tab and add TANGLED_KNOWN_HOSTS with that line as
its value.
A variable works here rather than a secret because a host key isn't secret. If the knot's key ever changes, the build fails instead of silently accepting the new one.
For a self-hosted knot, scan that host instead, and pass -p if it listens
somewhere other than 22:
ssh-keyscan -t ed25519 -p 2222 knot.example.com
6. Add the workflow #
Copy the Quick start block above into .github/workflows/mirror.yml and fill in
your repo value. Adding dry-run: true for the first run will show you what
would be pushed without pushing it.
Two details in that block are easy to miss. A branches: push trigger doesn't
fire when you push a tag, so without the tags: line a release wouldn't reach
the knot until your next commit. And ref: main matters because a tag push
otherwise checks out a detached HEAD, which the action refuses rather than
guessing which branch you meant.
If you move a tag rather than only adding new ones, for example a floating v1
or a rolling nightly, you also need force-tags: true. Git will not move an
existing tag without it, and because the push is atomic that one rejection takes
your branches down with it.
Automatic and manual runs #
Most setups end up with two workflows rather than one.
The automatic workflow runs on every push to main and sticks to safe
operations, with no forcing, pruning, or tag rewrites.
The manual workflow is a form in the Actions tab, and it's where the destructive
options live. Keeping them there rather than in the automatic workflow means
force and prune are a deliberate choice each time you run, instead of
settings that sit in a committed file and fire on the next push.
workflow_dispatch inputs render as form controls, so the block below shows up
as checkboxes and a dropdown:
name: Mirror to Tangled (manual)
on:
workflow_dispatch:
inputs:
dry_run:
description: 'Preview only, push nothing'
type: boolean
default: true
branches:
description: 'Branches to push. Blank = current branch. * = all.'
type: string
default: ''
on_divergence:
type: choice
options: [warn, fail, ignore]
default: warn
force_tags:
description: 'Let tags move (needed for rolling tags such as nightly)'
type: boolean
default: false
prune:
description: 'Delete refs absent locally. Requires branches = *'
type: boolean
default: false
permissions:
contents: read
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.TANGLED_KNOWN_HOSTS }}
branches: ${{ inputs.branches }}
on-divergence: ${{ inputs.on_divergence }}
force-tags: ${{ inputs.force_tags }}
prune: ${{ inputs.prune }}
dry-run: ${{ inputs.dry_run }}
dry_run defaults to true because the manual form is where the destructive
options are, and a preview is usually the right first step.
Both workflows are in this repo under .github/workflows/ if you'd rather copy
them directly. They're guarded by if: vars.TANGLED_REPO != '' so they stay
inert until you set that variable.
Note that ticking prune without setting branches to * gets rejected,
because pruning against a single branch would remove every other branch on the
knot.
Inputs #
| Input | Required | Default | Description |
|---|---|---|---|
repo |
yes | Path on the knot, exactly as it appears after the colon in the SSH remote. Works with a handle path (seth.bsky.social/my-repo) or a DID (did:plc:abc123/my-repo). |
|
ssh-key |
yes | Private SSH key registered on the account that owns the repo. Always pass this from a secret. | |
knot |
no | tangled.org |
Knot hostname. Change it for a self-hosted knot. |
port |
no | 22 |
SSH port on the knot. |
known-hosts |
no | "" |
Pinned host key in known_hosts format. Strongly recommended. |
skip-ci |
no | false |
Send the skip-ci push option so Spindle doesn't run pipelines for this push. |
address-family |
no | auto |
Restrict SSH to inet (IPv4) or inet6. Only needed for a self-hosted knot with broken IPv6. |
branches |
no | current branch | Branches to push, separated by spaces or newlines. * mirrors every branch. |
tags |
no | true |
Push tags as well. |
on-divergence |
no | warn |
What to do when the knot has refs you aren't pushing, or refs at different commits. One of warn, fail, ignore. |
allow-lfs |
no | false |
Push a repo that uses Git LFS. See the LFS section. |
mirror |
no | false |
Deletes things. Makes the knot an exact replica. Shorthand for branches: "*" plus prune, force, and force-tags, and it overrides all four. |
prune |
no | false |
Deletes things. Removes branches and tags on the knot that no longer exist locally. Requires branches: "*". |
force |
no | false |
Deletes things. Allows non-fast-forward updates. |
force-tags |
no | false |
Overwrites tags. Lets tags move on the knot. You need this for rolling tags like nightly. |
dry-run |
no | false |
Show what would be pushed without pushing it. |
Output #
| Output | Description |
|---|---|
pushed |
Space-separated list of what was pushed. |
What can destroy work on the Tangled side #
By default the action only adds commits or fast-forwards, and a push that would throw away work gets rejected instead. Four inputs change that, and each one means accepting that work on the Tangled side can disappear.
force: true discards commits that exist only on the knot, with no prompt
and no way to recover them. Use it only when GitHub is genuinely the single
source of truth and you're fine with anything pushed straight to Tangled being
lost.
prune: true deletes branches and tags on the knot that aren't in your local
set. On Tangled that can include a contributor's branch behind an open
branch-based pull request, which breaks the PR without notifying either of you.
force-tags: true overwrites an existing tag on the knot with your version.
Rolling tags need it, but it will also flatten a tag someone created on Tangled
that you don't have locally.
mirror: true turns on all three plus branches: "*". See below.
With all four left off, a disagreement between the two sides shows up as a failed build rather than as lost work.
Making the knot an exact replica #
If Tangled is a read-only copy and nobody ever contributes there, you probably
want it to match GitHub exactly instead of drifting apart. mirror: true does
that in a single input: the knot ends up with your exact set of branches and
tags, with extras deleted, diverged branches overwritten, and tags moved to match
yours.
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.TANGLED_KNOWN_HOSTS }}
mirror: true
The end result matches git push --mirror, without two of its behaviors: your
remote-tracking refs don't get copied onto the knot, and the divergence report
runs first, so the log lists every branch about to be deleted and every ref about
to be overwritten.
Don't use this if anyone might open a pull request on the Tangled side, since their branch will be gone on the next run.
When Tangled changes #
The default won't overwrite, so a knot that has moved ahead of you fails the next
run instead of getting steamrolled. The on-divergence check covers the case a
push result would never mention: refs your push doesn't touch at all, such as a
contributor's branch or a tag someone added on Tangled.
Pulling changes back from Tangled is manual. Tangled pull requests give you a patch URL:
curl -sL <patch_url> | git am
The work lands in your history with the contributor's authorship intact. Push to GitHub normally and it reaches Tangled again on the next mirror run.
There's no two-way sync, which is a deliberate choice. Automating both directions requires loop-breaking logic, and when the same file changes on both sides nothing can decide which version wins, since neither side is authoritative. GitLab, Gitea, and GitHub's own importer all mirror in one direction per repo for the same reason.
Spindle CI on the Tangled side #
Tangled has its own CI, Spindle, which runs workflows from .tangled/workflows
and triggers on push. If the repository you're mirroring happens to contain that
directory, every mirror push starts a Spindle run, re-testing code your GitHub
CI already tested.
Setting skip-ci: true sends Tangled's documented skip-ci push option so those
runs don't start:
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.TANGLED_KNOWN_HOSTS }}
skip-ci: true
It defaults to off, because a repo with .tangled/workflows in it usually has
that on purpose. Leave it off unless you're seeing duplicate CI runs.
If the knot doesn't accept push options, the action says so and points at
skip-ci: false rather than surfacing a raw git error.
skip-ci is ignored during a dry-run. A dry run sends no pack, and a knot
answers a push option in that situation with malformed pack stream, which
reads like an unrelated failure. Nothing can trigger a pipeline during a dry run
anyway, so the action drops the option and logs a notice.
Git LFS #
The action refuses LFS repositories unless you opt in, because it pushes LFS
pointer files but not the objects behind them. The mirror would look complete
while missing the actual file contents. Set allow-lfs: true if you're handling
the objects another way.
Examples #
Mirror every branch and tag, including deletions. Read the section above
first, since prune can remove contributor branches:
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.TANGLED_KNOWN_HOSTS }}
branches: '*'
prune: true
A repo with a rolling nightly tag. Without force-tags the run gets
rejected, because git won't move an existing tag and the push is atomic:
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.TANGLED_KNOWN_HOSTS }}
force-tags: true
Treat any divergence as a build failure:
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.TANGLED_KNOWN_HOSTS }}
on-divergence: fail
Mirror on stable releases only:
on:
release:
types: [published]
# ...
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
branches: main
A repo whose release workflow moves tags. If your release job re-points a
rolling tag, mirroring on push races it: the mirror checks out before the tag
moves, so the knot keeps the previous value until something else gets pushed.
Trigger after the release workflow finishes instead:
on:
workflow_run:
workflows: ["Release"]
types: [completed]
jobs:
mirror:
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v5
with:
ref: main
fetch-depth: 0
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.TANGLED_KNOWN_HOSTS }}
branches: main
force-tags: true
Careful wiring any input to ${{ inputs.something }} in a workflow like this.
On a workflow_run trigger there are no dispatch inputs, so those expand to an
empty string. Write ${{ inputs.dry_run || false }} instead. The action rejects
empty and non-boolean values rather than guessing, so you'll get a clear error
rather than silently different behaviour.
Self-hosted knot on a different port:
- uses: sethcottle/tangled-mirror@v1
with:
repo: seth.bsky.social/my-repo
ssh-key: ${{ secrets.TANGLED_SSH_KEY }}
known-hosts: ${{ vars.KNOT_KNOWN_HOSTS }}
knot: knot.example.com
port: 2222
Nothing about the knot hostname is assumed anywhere. The remote, the host key
lookup, and the URL in the rejected-key error message all come from the knot
input.
One thing that catches people out on a custom port: OpenSSH looks a host up as
[host]:port when the port isn't 22, and that won't match a plain host entry
in known_hosts. If you captured your key with ssh-keyscan knot.example.com
and no -p, you'd have the plain form and verification would fail. The action
writes both forms when port isn't 22, so either works.
How it works #
Host key verification. The action builds a known_hosts file scoped to the
single git push it runs, using GIT_SSH_COMMAND. Nothing is written to
~/.ssh, so later steps in the job don't inherit the key or a loosened host
policy. If you skip known-hosts, it falls back to ssh-keyscan and warns,
which trusts the host on first contact every run.
Explicit refspecs. The action pushes the refspecs you configured and nothing
else. It avoids git push --mirror, which force-updates every ref under refs/,
deletes remote refs that aren't local, and copies refs/remotes/* onto the
mirror, since a CI checkout carries remote-tracking refs.
The deletion behavior matters more on Tangled than it might elsewhere. Tangled
supports branch-based pull requests, where the contributor's branch lives inside
your repo, so a mirror that removes unrecognized refs will delete that branch and
break the PR. This action leaves refs it wasn't told about alone unless you turn
on prune.
Scoped deletions. prune: true requires branches: "*", because pruning
against a specific branch list would remove every other branch on the knot.
Pruning never touches anything outside refs/heads and refs/tags.
No implicit checkout. The action operates on whatever is already in the workspace and won't re-checkout, so it can't discard work from earlier steps.
Key handling. The private key goes to a temp file created under umask 077
and is removed by an EXIT trap whether the push succeeds or fails. Composite
actions can't declare post-run cleanup steps, so the trap is what guarantees it.
Troubleshooting #
this ssh key doesn't match any key published by the accounts that may push here
The knot accepted the connection but the key isn't registered to an account with
push access. Add the public half at https://tangled.org/settings/keys on the
account that owns the repo. This is easy to hit when an SSH agent offers several
keys, since the connection succeeds on one key and the push is then judged
against it.
shallow update not allowed
Set fetch-depth: 0 on actions/checkout. The default of 1 produces a shallow
clone, which knots reject.
'ssh-key' is empty
Usually a pull_request run from a fork, since GitHub doesn't expose secrets to
those. Mirror on push or release instead.
These tags point at something else on the knot
One of your tags disagrees with the knot's copy, typically a rolling tag like
nightly that CI re-points on every build. Git won't move an existing tag
without force, and because this push is atomic that single rejection takes the
branches with it. Set force-tags: true, or realign the tag locally.
The knot has commits that are not in this history
Someone pushed to Tangled directly. Look at what's there before deciding:
git fetch <remote> '+refs/heads/*:refs/tangled/*' && git log HEAD..refs/tangled/main
Then either merge it into GitHub or set force: true to discard it permanently.
'tags' is empty, or must be true or false
Boolean inputs have to be exactly true or false. yes, 1, and True are
all rejected. An empty value usually means you wired an input to
${{ inputs.something }} in a workflow that also triggers on workflow_run or
schedule, where dispatch inputs don't exist. Use ${{ inputs.something || false }}.
Nothing pushed, no error
Check whether dry-run is still set.
License #
MIT