Skip to content

Commit 1bd35d3

Browse files
committed
feat: route-git-token — runner-level private-git routing
The unsealed sibling of cargo-fetch's git-token input, for jobs that run plain cargo build or git clone on the runner. Exports the url.insteadOf rewrite as GIT_CONFIG_* environment entries — they die with the job, so nothing lands in a gitconfig file on self-hosted runners — and sets CARGO_NET_GIT_FETCH_WITH_CLI so cargo's fetches honor it. Invocations append behind each other. The defaults speak GitHub, but any authenticated https git host works: host takes an optional port, username carries the host's convention (oauth2 for GitLab, x-token-auth for Bitbucket), and path scopes the rewrite to a namespace, nested groups included. Host, username and path are each validated against their charset before they can reach a gitconfig key; minting stays with the caller. Selftest covers the GitHub defaults, a self-hosted GitLab group with port and username, the append behavior, and the invalid-path and invalid-host refusals. Documented in the README action list and docs/private-git-dependencies.md.
1 parent 15d5118 commit 1bd35d3

5 files changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Route git token
2+
description: >-
3+
Route git fetches on the RUNNER through a short-lived token: an url.insteadOf rewrite that
4+
authenticates https fetches with the token, plus CARGO_NET_GIT_FETCH_WITH_CLI=1 so cargo's
5+
fetches go through the git CLI, which honors the rewrite (cargo's libgit2 path does not). This is
6+
the unsealed sibling of `cargo-fetch`'s git-token input — for jobs that run plain `cargo build`
7+
or `git clone` on the runner instead of inside the sealed container. The rewrite lives in
8+
GIT_CONFIG_* environment entries exported via $GITHUB_ENV, NOT in a gitconfig file: it dies with
9+
the job, which matters on self-hosted runners, and it appends to (never clobbers) entries a
10+
previous invocation exported. The defaults speak GitHub, but any authenticated https git host
11+
works — the username carries the host's convention (GitHub App tokens: x-access-token; GitLab
12+
OAuth: oauth2; Bitbucket: x-token-auth; Gitea accepts the token under any username). Minting is
13+
the caller's business (e.g. actions/create-github-app-token, whose post step revokes the token);
14+
scope the token there — the rewrite is a transport detail, the token is the security boundary.
15+
16+
inputs:
17+
token:
18+
description: >-
19+
Token the fetches authenticate with. Pass a secret carrying read access on the repositories
20+
the job needs; the action masks it in logs either way.
21+
required: true
22+
host:
23+
description: >-
24+
Git host to route, optionally with a port (my.gitlab.example:8443). Default github.com.
25+
required: false
26+
default: "github.com"
27+
username:
28+
description: >-
29+
Userinfo name the token is presented under — the host's convention, not a real user.
30+
Default x-access-token (GitHub App installation tokens; GitHub PATs accept it too).
31+
required: false
32+
default: "x-access-token"
33+
path:
34+
description: >-
35+
Restrict the rewrite to a namespace under the host (an owner, or a nested group like
36+
my-group/sub-group). Empty routes every fetch on the host, matching `cargo-fetch`'s
37+
in-container behavior. Restricting is about predictability, not security — a rewrite for a
38+
repository the token cannot read fails exactly like no rewrite at all.
39+
required: false
40+
default: ""
41+
42+
runs:
43+
using: composite
44+
steps:
45+
- name: Export the rewrite
46+
shell: bash
47+
env:
48+
ROUTE_TOKEN: ${{ inputs.token }}
49+
ROUTE_HOST: ${{ inputs.host }}
50+
ROUTE_USERNAME: ${{ inputs.username }}
51+
ROUTE_PATH: ${{ inputs.path }}
52+
run: bash "$GITHUB_ACTION_PATH/route.sh"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
# Export an url.insteadOf rewrite as GIT_CONFIG_* environment entries via
3+
# $GITHUB_ENV, appending after any entries an earlier invocation exported.
4+
# ROUTE_TOKEN the token (required; masked before anything else)
5+
# ROUTE_HOST git host, optionally with a port (default github.com)
6+
# ROUTE_USERNAME userinfo name the token is presented under
7+
# ROUTE_PATH optional namespace under the host to scope the rewrite to
8+
set -euo pipefail
9+
10+
[ -n "${ROUTE_TOKEN:-}" ] || { echo "::error::token input is empty"; exit 1; }
11+
printf '::add-mask::%s\n' "$ROUTE_TOKEN"
12+
13+
# Every validated piece ends up inside a gitconfig KEY; these gates keep
14+
# injection structurally impossible.
15+
HOST="${ROUTE_HOST:-github.com}"
16+
if ! [[ "$HOST" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{1,5})?$ ]]; then
17+
echo "::error::host '${HOST}' is not a valid host[:port]"
18+
exit 1
19+
fi
20+
21+
USERNAME="${ROUTE_USERNAME:-x-access-token}"
22+
if ! [[ "$USERNAME" =~ ^[A-Za-z0-9._-]+$ ]]; then
23+
echo "::error::username '${USERNAME}' is not a valid userinfo name"
24+
exit 1
25+
fi
26+
27+
PREFIX="https://${HOST}/"
28+
if [ -n "${ROUTE_PATH:-}" ]; then
29+
# One or more /-separated segments, each of the forge login/group charset
30+
# (GitLab namespaces nest, so a single segment would be too narrow).
31+
if ! [[ "$ROUTE_PATH" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?(/[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)*$ ]]; then
32+
echo "::error::path '${ROUTE_PATH}' is not a valid namespace path"
33+
exit 1
34+
fi
35+
PREFIX="https://${HOST}/${ROUTE_PATH}/"
36+
fi
37+
38+
# Append behind existing entries: GIT_CONFIG_COUNT is the authoritative
39+
# cursor, whether set by a previous invocation or by the workflow itself.
40+
N="${GIT_CONFIG_COUNT:-0}"
41+
{
42+
echo "GIT_CONFIG_COUNT=$((N + 1))"
43+
echo "GIT_CONFIG_KEY_${N}=url.https://${USERNAME}:${ROUTE_TOKEN}@${PREFIX#https://}.insteadOf"
44+
echo "GIT_CONFIG_VALUE_${N}=${PREFIX}"
45+
# cargo's libgit2 path ignores git config rewrites; the CLI honors them.
46+
echo "CARGO_NET_GIT_FETCH_WITH_CLI=true"
47+
} >> "$GITHUB_ENV"
48+
49+
echo "Routing ${PREFIX} fetches through the provided token (entry ${N})."

‎.github/workflows/selftest.yml‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,3 +1474,63 @@ jobs:
14741474
retry_transient always' >/dev/null 2>&1 || status=$?
14751475
[ "$status" = "2" ] || { echo "::error::a non-numeric RETRY_ATTEMPTS did not fail fast ($status)"; exit 1; }
14761476
echo "✓ registry timeouts retry; build errors fail once keeping their status; the contract enforces itself"
1477+
1478+
# route-git-token: the exported GIT_CONFIG_* entries resolve as git
1479+
# config in later steps, a second invocation appends behind the first
1480+
# instead of clobbering it, a non-GitHub host carries its own username
1481+
# convention, and host/path values that could not name a forge namespace
1482+
# are refused before anything reaches the environment.
1483+
selftest-route-git-token:
1484+
name: exercise the route-git-token action
1485+
runs-on: ubuntu-latest
1486+
steps:
1487+
- uses: actions/checkout@v7
1488+
1489+
- name: route a github.com namespace (defaults)
1490+
uses: ./.github/actions/route-git-token
1491+
with:
1492+
token: not-a-real-token
1493+
path: octo-test
1494+
1495+
- name: route a self-hosted GitLab group, appending
1496+
uses: ./.github/actions/route-git-token
1497+
with:
1498+
token: second-fake-token
1499+
host: gitlab.example.com:8443
1500+
username: oauth2
1501+
path: my-group/sub-group
1502+
1503+
- name: the rewrites resolve and cargo is switched to the git CLI
1504+
shell: bash
1505+
run: |
1506+
set -euo pipefail
1507+
[ "$GIT_CONFIG_COUNT" = "2" ] || { echo "::error::expected 2 entries, got ${GIT_CONFIG_COUNT:-none}"; exit 1; }
1508+
[ "$CARGO_NET_GIT_FETCH_WITH_CLI" = "true" ] || { echo "::error::cargo git-CLI switch missing"; exit 1; }
1509+
got="$(git config --get 'url.https://x-access-token:not-a-real-token@github.com/octo-test/.insteadOf')"
1510+
[ "$got" = "https://github.com/octo-test/" ] || { echo "::error::github rewrite did not resolve (got '$got')"; exit 1; }
1511+
got="$(git config --get 'url.https://oauth2:second-fake-token@gitlab.example.com:8443/my-group/sub-group/.insteadOf')"
1512+
[ "$got" = "https://gitlab.example.com:8443/my-group/sub-group/" ] || { echo "::error::gitlab rewrite did not resolve (got '$got')"; exit 1; }
1513+
1514+
- name: an invalid path is refused
1515+
id: bad-path
1516+
continue-on-error: true
1517+
uses: ./.github/actions/route-git-token
1518+
with:
1519+
token: t
1520+
path: "evil/../traversal"
1521+
1522+
- name: an invalid host is refused
1523+
id: bad-host
1524+
continue-on-error: true
1525+
uses: ./.github/actions/route-git-token
1526+
with:
1527+
token: t
1528+
host: "github.com/../evil"
1529+
1530+
- name: the refusals actually happened
1531+
shell: bash
1532+
run: |
1533+
[ "${{ steps.bad-path.outcome }}" = "failure" ] \
1534+
|| { echo "::error::invalid path was accepted"; exit 1; }
1535+
[ "${{ steps.bad-host.outcome }}" = "failure" ] \
1536+
|| { echo "::error::invalid host was accepted"; exit 1; }

‎README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,20 @@ See [the action's README](.github/actions/cargo-install/README.md) for the secur
322322
Run a `cargo-install`'d tool from the shared cache, sealed and network-isolated by default.
323323
See [the action's README](.github/actions/cargo-use/README.md).
324324

325+
### `route-git-token`
326+
327+
Route git fetches ON THE RUNNER through a short-lived token: an `url.insteadOf` rewrite exported as `GIT_CONFIG_*` environment entries (they die with the job — nothing lands in a gitconfig file, which matters on self-hosted runners) plus `CARGO_NET_GIT_FETCH_WITH_CLI` so cargo's fetches honor it.
328+
The unsealed sibling of `cargo-fetch`'s `git-token` input, for jobs that run plain `cargo build` or `git clone` outside the container.
329+
The defaults speak GitHub, but any authenticated https git host works: `host:` and `username:` carry the host's convention (GitLab OAuth: `oauth2`; Bitbucket: `x-token-auth`), and `path:` optionally scopes the rewrite to a namespace — nested groups included.
330+
Invocations append, so two tokens for two hosts or namespaces coexist.
331+
332+
```yaml
333+
- uses: gronke/rust-ci/.github/actions/route-git-token@main
334+
with:
335+
token: ${{ steps.deps-token.outputs.token }}
336+
path: my-org
337+
```
338+
325339
### `publish-dry-run`
326340

327341
A release gate split into **network-with-no-code** then **code-with-no-network**.
@@ -423,6 +437,7 @@ The token must carry `contents: read` on every private dependency repository.
423437
The default `GITHUB_TOKEN` only grants access to the workflow's own repository, so a cross-repository dependency needs a fine-grained PAT or a GitHub App installation token, stored as a secret.
424438
See [docs/private-git-dependencies.md](docs/private-git-dependencies.md) for the GitHub App setup and an in-workflow minting example.
425439
The sealed `lint-and-test-docker` passes run `--offline` against the cache `cargo-fetch` populated, so they need no token.
440+
Jobs that build on the runner instead of in the container route the same token with [`route-git-token`](#route-git-token).
426441

427442
## Cache-size operations
428443

‎docs/private-git-dependencies.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ Mint the token just before `cargo-fetch` and hand the output to `git-token`:
5858

5959
The token expires after one hour, is masked in logs by the action, and is revoked automatically in the action's `post` step, so it never lingers.
6060

61+
### Without the sealed container
62+
63+
A job that runs plain `cargo build` or `git clone` on the runner — no `cargo-fetch`, no seal — routes the same token with `route-git-token`:
64+
65+
```yaml
66+
- name: Route private git fetches through the token
67+
uses: gronke/rust-ci/.github/actions/route-git-token@main
68+
with:
69+
token: ${{ steps.deps-token.outputs.token }}
70+
path: ${{ github.repository_owner }} # optional; empty routes the whole host
71+
```
72+
73+
The action is not GitHub-bound: `host:` (with an optional port) and `username:` route any authenticated https git host — a self-hosted GitLab takes `host: gitlab.example.com` with `username: oauth2` and a nested `path: group/sub-group`.
74+
Only the minting is ecosystem-specific, and that stays with the caller.
75+
76+
It exports the `url.insteadOf` rewrite as `GIT_CONFIG_*` environment entries (they die with the job — nothing lands in a gitconfig file, which matters on self-hosted runners) and sets `CARGO_NET_GIT_FETCH_WITH_CLI` so cargo's fetches honor it. The rewrite is a transport detail: scope the TOKEN, as above — a rewrite for a repository the token cannot read fails exactly like no rewrite at all.
77+
6178
## Fine-grained PAT (simpler alternative)
6279

6380
Create a fine-grained PAT with **Contents: Read** on each private dependency repository, store it as a secret, and pass it directly:

0 commit comments

Comments
 (0)