From a11c45aabd4d30102f628cc35ef54318c78db823 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Fri, 8 May 2026 12:56:52 +0200 Subject: [PATCH 1/5] Replace with cached composite action Replaces the upstream Docker/go-run approach with a composite action that compiles gitops-pusher once and caches the static binary via actions/cache. Subsequent runs restore from cache and execute directly, reducing runtime from ~60-90s to ~2-3s. - Composite action with no Docker dependency - Binary cached by OS, arch, and pinned Tailscale commit hash - All inputs flow through env vars to prevent script injection - Strict shell mode (set -euo pipefail) on all steps - Input validation for action and auth methods - Configurable Tailscale version via tailscale-release input --- .github/dependabot.yml | 6 -- README.md | 133 ++++++++++++------------------------- action.yml | 147 ++++++++++++++++++++++++++++++++--------- 3 files changed, 156 insertions(+), 130 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index ca79ca5..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 2 -updates: - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly diff --git a/README.md b/README.md index f2fdfe0..0cd6fc8 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,85 @@ -# GitHub Action to Sync Tailscale ACLs +# Tailscale GitOps Action -This GitHub action lets you manage your [tailnet policy file](https://tailscale.com/kb/1018/acls/) using a -[GitOps](https://about.gitlab.com/topics/gitops/) workflow. With this GitHub -action you can automatically manage your tailnet policy file using a git repository -as your source of truth. +A GitHub Action to manage your [Tailscale ACL policy](https://tailscale.com/kb/1018/acls) using a GitOps workflow. This is a drop-in replacement for [`tailscale/gitops-acl-action`](https://github.com/tailscale/gitops-acl-action) that caches a pre-compiled `gitops-pusher` binary instead of compiling it from source on every run, reducing execution time from 60–90 seconds to **2–3 seconds**. -## Inputs - -### `tailnet` - -**Required** The name of your tailnet. You can find it by opening [the admin -panel](https://login.tailscale.com/admin) and copying down the name next to the -Tailscale logo in the upper left hand corner of the page. - -### `oauth-client-id` and `audience` - -**Optional** The ID and audience for a [federated identity](https://tailscale.com/kb/1581/workload-identity-federation) -for your tailnet. The federated identity must have the `policy_file` scope. - -Either `api-key`, `oauth-client-id` and `oauth-secret`, or `oauth-client-id` and `audience` are required. - -### `api-key` - -**Optional** An API key authorized for your tailnet. You can get one [in the -admin panel](https://login.tailscale.com/admin/settings/keys). -Either `api-key`, `oauth-client-id` and `oauth-secret`, or `oauth-client-id` and `audience` are required. - -Please note that API keys will expire in 90 days. Set up a monthly event to -rotate your Tailscale API key, or use a trust credential (OAuth client or federated identity). - -### `oauth-client-id` and `oauth-secret` - -**Optional** The ID and secret for an [OAuth client](https://tailscale.com/kb/1215/oauth-clients) -for your tailnet. The client must have the `policy_file` scope. - -Either `api-key`, `oauth-client-id` and `oauth-secret`, or `oauth-client-id` and `audience` are required. - -### `policy-file` - -**Optional** The path to your policy file in the repository. If not set this -defaults to `policy.hujson` in the root of your repository. - -### `action` - -**Required** One of `test` or `apply`. If you set `test`, the action will run -ACL tests and not update the ACLs in Tailscale. If you set `apply`, the action -will run ACL tests and then update the ACLs in Tailscale. This enables you to -use pull requests to make changes with CI stopping you from pushing a bad change -out to production. - -## Getting Started - -Set up a new GitHub repository that will contain your tailnet policy file. Open the [Access Controls page of the admin console](https://login.tailscale.com/admin/acls) and copy your policy file to -a file in that repo called `policy.hujson`. - -If you want to change this name to something else, you will need to add the -`policy-file` argument to the `with` blocks in your GitHub Actions config. - -Copy this file to `.github/workflows/tailscale.yml`. +## Usage ```yaml name: Sync Tailscale ACLs on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: acls: + runs-on: ubuntu-latest permissions: contents: read - id-token: write # This is required for the Tailscale action to request a JWT from GitHub - runs-on: ubuntu-latest + id-token: write steps: - - uses: actions/checkout@v6 - - - name: Fetch version-cache.json - uses: actions/cache@v5 - with: - path: ./version-cache.json - key: version-cache.json-${{ github.run_id }} - restore-keys: | - version-cache.json- + - uses: actions/checkout@v4 - name: Deploy ACL if: github.event_name == 'push' - id: deploy-acl - uses: tailscale/gitops-acl-action@v1 + uses: matchory/tailscale-gitops-action@v1 with: oauth-client-id: ${{ secrets.TS_OAUTH_ID }} audience: ${{ secrets.TS_AUDIENCE }} tailnet: ${{ secrets.TS_TAILNET }} + policy-file: ./policy.hujson action: apply - name: Test ACL if: github.event_name == 'pull_request' - id: test-acl - uses: tailscale/gitops-acl-action@v1 + uses: matchory/tailscale-gitops-action@v1 with: oauth-client-id: ${{ secrets.TS_OAUTH_ID }} audience: ${{ secrets.TS_AUDIENCE }} tailnet: ${{ secrets.TS_TAILNET }} + policy-file: ./policy.hujson action: test ``` -Generate a new federated identity. See [here](https://login.tailscale.com/admin/settings/keys) for instructions. +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `tailnet` | Yes | — | Your tailnet name (e.g. `example.com`, `your-org.github`) | +| `api-key` | No | — | Tailscale API key (expires every 90 days) | +| `oauth-client-id` | No | — | OAuth or OIDC Federated Identity Client ID | +| `oauth-secret` | No | — | OAuth Client Secret | +| `audience` | No | — | OIDC Federated Identity Audience | +| `policy-file` | Yes | `./policy.hujson` | Path to your policy file in the repository | +| `action` | Yes | — | `test` (validate only) or `apply` (validate and push) | +| `tailscale-release` | No | `b4d39e...` | Tailscale commit hash to build `gitops-pusher` from | + +### Authentication + +Exactly **one** of the following authentication methods must be provided: -Then open the secrets settings for your repo and add two secrets: +1. **API Key** — set `api-key` (note: keys expire after 90 days) +2. **OAuth Client** — set `oauth-client-id` and `oauth-secret` +3. **OIDC Federated Identity** — set `oauth-client-id` and `audience` (requires `id-token: write` permission) -* `TS_OAUTH_ID`: Your federated identity's client ID -* `TS_AUDIENCE`: Your federated identity's audience -* `TS_TAILNET`: Your tailnet's name (it's next to the logo on the upper - left-hand corner of the [admin panel](https://login.tailscale.com/admin/machines)) +## How It Works -Once you do that, commit the changes and push them to GitHub. You will have CI -automatically test and push changes to your tailnet policy file to Tailscale. +The action compiles Tailscale's [`gitops-pusher`](https://pkg.go.dev/tailscale.com/cmd/gitops-pusher) as a static binary on the first run and caches it using [`actions/cache`](https://github.com/actions/cache). The cache key includes the runner OS, architecture, and the pinned Tailscale commit hash, so subsequent runs skip compilation entirely and restore the binary from cache. -## Developer guide +| Run | What happens | Time | +|-----|-------------|------| +| First (cold cache) | Installs Go, compiles binary, caches it | ~25s | +| Subsequent (warm cache) | Restores binary from cache, executes it | ~2–3s | -### Release process +When using OIDC federated identity, the action fetches an ID token from GitHub's OIDC provider before invoking the binary. -To create a new minor or patch release: +### Updating the Tailscale version -- push the new tag to the main branch +The `tailscale-release` input controls which commit of `gitops-pusher` is compiled. Changing this value (either in this action's default or as an input in your workflow) automatically invalidates the cache and triggers a one-time recompilation. -- create a new GitHub release with a description of the changes in this release +## License -- repush the latest major release tag to point at the new latest release. -For example, if you are creating a `v1.3.1` release, you want to additionally tag it with `v1` tag. -This approach follows the [official GitHub actions versioning guidelines](https://docs.github.com/en/actions/creating-actions/about-custom-actions#using-tags-for-release-management). +MIT diff --git a/action.yml b/action.yml index 4b9b07a..05ad76e 100644 --- a/action.yml +++ b/action.yml @@ -1,8 +1,9 @@ name: "Sync Tailscale ACLs" -description: "Push changes to Tailscale and run ACL tests in CI" +description: "Push changes to Tailscale ACLs and run ACL tests in CI (pre-compiled for speed)" + inputs: tailnet: - description: "Tailnet name (eg. example.com, xe.github, tailscale.org.github)" + description: "Tailnet name (e.g. example.com, your-org.github)" required: true api-key: description: "Tailscale API key" @@ -17,49 +18,129 @@ inputs: description: "Tailscale OIDC Federated Identity Audience" required: false policy-file: - description: "Path to policy file" + description: "Path to the policy file in the repository" required: true - default: ./policy.hujson + default: "./policy.hujson" action: - description: "Action to take (test/apply)" + description: "test or apply" required: true + tailscale-release: + description: "Tailscale release version to use" + required: false + default: "b4d39e2fd92538384aa7388fdbeda0ec51973bfc" + runs: using: "composite" steps: - - name: Check Auth Info Empty - if: ${{ inputs['api-key'] == '' && inputs['oauth-secret'] == '' && inputs['oauth-client-id'] == ''}} - shell: bash - run: | - echo "::error title=⛔ error hint::API Key, OAuth secret, or OAuth client ID and audience must be specified. Maybe you need to populate it in the Secrets for your workflow, see more in https://docs.github.com/en/actions/security-guides/encrypted-secrets and https://tailscale.com/s/trust-credentials - exit 1 - - name: Check Conflicting Auth Info - if: ${{ (inputs['api-key'] != '' && (inputs['oauth-secret'] != '' || inputs['audience'] != '')) || (inputs['oauth-secret'] != '' && (inputs['api-key'] != '' || inputs['audience'] != '')) || (inputs['audience'] != '' && (inputs['api-key'] != '' || inputs['oauth-secret'] != '')) }} + - name: Validate inputs shell: bash + env: + INPUT_API_KEY: ${{ inputs.api-key }} + INPUT_OAUTH_CLIENT_ID: ${{ inputs.oauth-client-id }} + INPUT_OAUTH_SECRET: ${{ inputs.oauth-secret }} + INPUT_AUDIENCE: ${{ inputs.audience }} + INPUT_ACTION: ${{ inputs.action }} run: | - echo "::error title=⛔ error hint::only one of API Key, OAuth secret, or OAuth client ID and audience should be specified." - exit 1 - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + set -euo pipefail + + if [[ "${INPUT_ACTION}" != "test" && "${INPUT_ACTION}" != "apply" ]]; then + echo "::error::Invalid action '${INPUT_ACTION}'. Must be 'test' or 'apply'." + exit 1 + fi + + auth_count=0 + + if [[ -n "${INPUT_API_KEY}" ]]; then + auth_count=$((auth_count + 1)) + fi + + if [[ -n "${INPUT_OAUTH_CLIENT_ID}" && -n "${INPUT_OAUTH_SECRET}" ]]; then + auth_count=$((auth_count + 1)) + fi + + if [[ -n "${INPUT_OAUTH_CLIENT_ID}" && -n "${INPUT_AUDIENCE}" ]]; then + auth_count=$((auth_count + 1)) + fi + + if [[ "${auth_count}" -eq 0 ]]; then + echo "::error::No authentication method provided. Provide one of: api-key, oauth-client-id + oauth-secret, or oauth-client-id + audience." + exit 1 + fi + + if [[ "${auth_count}" -gt 1 ]]; then + echo "::error::Multiple conflicting authentication methods provided. Provide only one of: api-key, oauth-client-id + oauth-secret, or oauth-client-id + audience." + exit 1 + fi + + - name: Cache gitops-pusher binary + id: cache + uses: actions/cache@v4 + with: + path: ${{ github.action_path }}/bin + key: gitops-pusher-${{ runner.os }}-${{ runner.arch }}-${{ inputs.tailscale-release }} + + - name: Setup Go + if: steps.cache.outputs.cache-hit != 'true' + uses: actions/setup-go@v5 with: - go-version: 1.25.6 + go-version: "1.25.x" cache: false - - name: Fetch ID token - if: ${{ inputs['oauth-client-id'] != '' && inputs['audience'] != '' }} + + - name: Build gitops-pusher + if: steps.cache.outputs.cache-hit != 'true' shell: bash - id: fetch-id-token + env: + ACTION_PATH: ${{ github.action_path }} + TAILSCALE_RELEASE: ${{ inputs.tailscale-release }} run: | - ID_TOKEN=$(curl -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${{ inputs.audience }}" | jq -r '.value') - echo "::add-mask::ID_TOKEN" - echo "id_token=$ID_TOKEN" >> $GITHUB_OUTPUT - - name: Gitops pusher + set -euo pipefail + CGO_ENABLED=0 go install tailscale.com/cmd/gitops-pusher@${TAILSCALE_RELEASE} + mkdir -p "${ACTION_PATH}/bin" + cp "$(go env GOPATH)/bin/gitops-pusher" "${ACTION_PATH}/bin/" + + - name: Fetch OIDC token + id: fetch-id-token + if: ${{ inputs.oauth-client-id != '' && inputs.audience != '' }} shell: bash env: - # gitops-pusher will use OAUTH_ID and OAUTH_SECRET or - # OAUTH_ID and ID_TOKEN if non-empty, - # otherwise it will use API_KEY. - TS_OAUTH_ID: "${{ inputs.oauth-client-id }}" - TS_OAUTH_SECRET: "${{ inputs.oauth-secret }}" - TS_ID_TOKEN: "${{ steps.fetch-id-token.outputs.id_token }}" - TS_API_KEY: "${{ inputs.api-key }}" - TS_TAILNET: "${{ inputs.tailnet }}" - run: go run tailscale.com/cmd/gitops-pusher@b4d39e2fd92538384aa7388fdbeda0ec51973bfc "--policy-file=${{ inputs.policy-file }}" "${{ inputs.action }}" + INPUT_AUDIENCE: ${{ inputs.audience }} + run: | + set -euo pipefail + + if [[ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]]; then + echo "::error::ACTIONS_ID_TOKEN_REQUEST_URL is not set. Ensure the workflow has 'id-token: write' permission." + exit 1 + fi + if [[ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then + echo "::error::ACTIONS_ID_TOKEN_REQUEST_TOKEN is not set. Ensure the workflow has 'id-token: write' permission." + exit 1 + fi + RESPONSE=$(curl -fsSL \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${INPUT_AUDIENCE}") + + ID_TOKEN=$(echo "${RESPONSE}" | jq -r '.value') + + if [[ -z "${ID_TOKEN}" || "${ID_TOKEN}" == "null" ]]; then + echo "::error::Failed to fetch OIDC ID token from GitHub." + exit 1 + fi + + echo "::add-mask::${ID_TOKEN}" + echo "id-token=${ID_TOKEN}" >> "${GITHUB_OUTPUT}" + + - name: Run gitops-pusher + shell: bash + env: + TS_OAUTH_ID: ${{ inputs.oauth-client-id }} + TS_OAUTH_SECRET: ${{ inputs.oauth-secret }} + TS_ID_TOKEN: ${{ steps.fetch-id-token.outputs.id-token }} + TS_API_KEY: ${{ inputs.api-key }} + TS_TAILNET: ${{ inputs.tailnet }} + ACTION_PATH: ${{ github.action_path }} + INPUT_POLICY_FILE: ${{ inputs.policy-file }} + INPUT_ACTION: ${{ inputs.action }} + run: | + set -euo pipefail + exec "${ACTION_PATH}/bin/gitops-pusher" "--policy-file=${INPUT_POLICY_FILE}" "${INPUT_ACTION}" From 0a7512296ca65ce7e695bcf86a973335d1df5da6 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Fri, 8 May 2026 12:57:44 +0200 Subject: [PATCH 2/5] Restore dependabot config for actions updates --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly From 5e4a68080fa8e11b12116125a143b43667e4e39b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:02:32 +0000 Subject: [PATCH 3/5] build(deps): bump actions/setup-go from 5 to 6 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 05ad76e..127d548 100644 --- a/action.yml +++ b/action.yml @@ -81,7 +81,7 @@ runs: - name: Setup Go if: steps.cache.outputs.cache-hit != 'true' - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: "1.25.x" cache: false From 3f3b994e60e2885b079321015aef3bb368bdee39 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 11:02:35 +0000 Subject: [PATCH 4/5] build(deps): bump actions/cache from 4 to 5 Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 05ad76e..e3845c9 100644 --- a/action.yml +++ b/action.yml @@ -74,7 +74,7 @@ runs: - name: Cache gitops-pusher binary id: cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ github.action_path }}/bin key: gitops-pusher-${{ runner.os }}-${{ runner.arch }}-${{ inputs.tailscale-release }} From 5109778f4d8ee28ea30286fb6f0642cc4eb70f92 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Fri, 8 May 2026 13:07:14 +0200 Subject: [PATCH 5/5] Fix action name and add branding (#3) --- action.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 2befe9d..87d88fc 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,10 @@ -name: "Sync Tailscale ACLs" +name: "Tailscale GitOps ACL Sync" description: "Push changes to Tailscale ACLs and run ACL tests in CI (pre-compiled for speed)" +branding: + icon: "shield" + color: "blue" + inputs: tailnet: description: "Tailnet name (e.g. example.com, your-org.github)"