Skip to content

Setting Tremvok up

Pick a target, add the job, grant the permissions that target needs. The action reference has every input and the exact permission block per target; this page is the task-shaped version.

The workflow, per target

github-pages

actions/deploy-pages requires pages: write and the github-pages environment, and a composite action can declare neither. So the action builds and stages the artifact, and a job of yours publishes it. The environment name is fixed. GitHub creates github-pages when you set the Pages source to "GitHub Actions", and deploy-pages expects that name, so this job is boilerplate, not a decision:

permissions: { contents: read }

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: MagmaMoose/tremvok@v2
        with:
          target: github-pages
          pages-strict: true

  deploy:
    needs: build
    if: github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions: { pages: write, id-token: write }
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v5

There's nothing to set for a pull request. GitHub Pages has one site and no preview destination, so publishing to it is publishing, and a pull request (which resolves to mode: preview) builds and checks without staging an artifact. A dry run does the same. The build is the check, and it can't publish by accident.

Set Settings → Pages → Source = "GitHub Actions" once per repository.

Search, social and agent metadata

Both docs targets finish the build by giving every page the metadata MkDocs Material leaves out. The step edits the built HTML, is on by default (pages-seo), and needs no credentials and no network:

  • Its own meta description. Material prints site_description on every page without a description: in its front matter, so a search engine sees one sentence repeated across the site. Each of those pages gets its first paragraph of prose instead, at most 155 characters and unique across the site. Warnings, tables, code, lists and lines that are only links are skipped. The home page keeps site_description, and when its title is the bare site name, the lead clause of site_description joins it (Tremvok - One GitHub Action for the whole deploy side).
  • Open Graph and Twitter tags, so a link pasted into Slack or LinkedIn unfurls as a card.
  • A JSON-LD graph: the WebSite and its publisher on every page, and a TechArticle and a BreadcrumbList built from the nav on every page but the home page.
  • A markdown twin: the page's source at <page>/index.md (the llmstxt.org convention), announced with <link rel="alternate" type="text/markdown">, its relative links resolved the way MkDocs resolves them for the HTML so they still work from where the twin lives. On cloudflare-docs, llms.txt links the twins.

Configure it under extra.seo in mkdocs.yml. extra is a dict, so it merges through INHERIT and a shared base can set it once for every site. Every key is optional:

extra:
  seo:
    locale: en_GB                  # og:locale, and inLanguage (en-GB) in the JSON-LD
    image:                         # the link-preview card
      url: https://www.example.com/og/card.png
      width: 1200
      height: 630
      alt: Example docs            # defaults to site_name
    publisher:                     # use the @id your own site's JSON-LD already has
      type: Organization
      id: https://www.example.com/#organization
      name: Example
      url: https://www.example.com/
      logo: https://www.example.com/logo.png
      same_as: [https://github.com/example]
    # author: the same shape; defaults to the publisher, then to site_author
    # twitter: "@handle"

A page keeps whatever it already has: a description that is not site_description (from front matter or a hook of your own), Open Graph tags once og:title is present, Twitter tags once twitter:card is, JSON-LD once any application/ld+json block is, and a twin or twin link that exists already. The same rules make a second run change nothing.

Canonical links, og:url and the JSON-LD need an absolute address, which is site_url. On cloudflare-docs the router address stands in when site_url is unset, and the step warns, because MkDocs has then written no canonical links and an empty sitemap.xml.

Installing a dependency from a private git repository

A docs build often pins its theme straight to a private repository:

mkdocs-yourtheme @ git+https://git.example.invalid/your-org/theme.git@v1.1.2#subdirectory=theme

That clone is git's own, several processes below the action, so there is no flag to pass a token on. build-git-credentials leaves one where git will find it, one <host> <username>:<token> per line:

      - uses: MagmaMoose/tremvok@v2
        with:
          target: github-pages
          build-git-credentials: |
            git.example.invalid  x-access-token:${{ steps.app-token.outputs.token }}

Each line becomes a url.<credentialled>.insteadOf rewrite carried in GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n, which live in the job's environment and end with the job. Requirements files keep pinning the plain URL, so they stay reviewable. Don't reach for git config --global in a step of your own instead: a self-hosted runner is a shared, long-lived machine, and a global rewrite leaves the token in ~/.gitconfig for whatever runs there next.

Write the username out, because the forges disagree about it: x-access-token goes with a GitHub App installation token, oauth2 with a GitLab one. Prefer a short-lived App token (actions/create-github-app-token) over a personal access token — it expires within the hour and it only reaches the repositories the installation was given.

cloudflare-workers takes the same input for the same reason: its build runs on the runner too.

Every token is masked the moment it is read, and none is ever echoed. A line the action refuses is named by its index and its host, and the host is only quoted when it looks like one, because a bare token pasted onto a line would otherwise be printed into an annotation as public as the repository.

cloudflare-docs

The same strict MkDocs build as github-pages, published to Cloudflare Workers Static Assets instead of GitHub Pages. The canonical address is https://<host>/<repo>/, and one hostname serves every repository by path:

permissions: { contents: read, pull-requests: write }

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: MagmaMoose/tremvok@v2
        with:
          target: cloudflare-docs
          cloudflare-docs-host: docs.magmamoose.com
          cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

One job, where github-pages needs two. That second job exists only because actions/deploy-pages requires pages: write and the github-pages environment, which a composite action cannot declare. Wrangler requires neither.

Three things have to exist before the first run:

  1. site_url is the canonical address. Set it to https://<host>/<repo>/. MkDocs emits <link rel="canonical"> and its sitemap from it, so leaving it pointed at the old host publishes absolute links to somewhere that is no longer the address.
  2. A wrangler.toml at your repository root. Copy workers/docs-site/wrangler.toml and set name to docs-<repo>. It declares [assets] and, deliberately, no route and no workers_dev URL.
  3. A [[services]] block in the router's config, binding <REPO> to docs-<repo>. Without it the router 404s your path — a quiet omission, since your own build stays green.

A pull request publishes nothing. These Workers carry no route and workers_dev = false, so there is no disposable address a preview could be served from; the strict build is the check. That is the same position github-pages is in, arrived at differently.

Why the router uses a service binding

The site Workers are unreachable over HTTP. The router on <host> calls them through a service binding, which is an in-process dispatch on Cloudflare's network rather than a request on the wire. An HTTP proxy would need a public origin hostname for each site — exactly what workers_dev = false exists to prevent — and proxying an Access-gated origin would need the router to hold a service token, at which point Access is gating the router rather than the person visiting.

What the router serves itself

The host root is the router's own, so the fleet has one front door for people, crawlers and agents:

Path What it is
/ A landing page listing every site, with its title and summary, or a 302 to your own documentation hub (below)
/llms.txt An llms.txt index linking each site's llms.txt and llms-full.txt
/sitemap.xml A sitemap index of every site's sitemap.xml
/robots.txt Allows everything, declares content signals, names the sitemap index
/.well-known/security.txt The RFC 9116 security contact
/.well-known/ai-catalog.json, /.well-known/api-catalog Pointers to the docs MCP server's card, for agent registries

A site's title and summary there are read from its own llms.txt, which the build writes from site_name and site_description. Those two keys in your mkdocs.yml are what the root says about your site, and the [[services]] block is the only thing to register.

The router also sets the security headers every response on the host carries, serves .txt and .md as UTF-8, and answers Accept: text/markdown on a page with that page's index.md, so a site Worker needs none of it.

If you keep a documentation hub elsewhere, such as a Docs page on your main site, set LANDING_REDIRECT under [vars] in the router's wrangler.toml to its https URL. / then answers a 302 there instead of serving the landing page, keeping the discovery Link header. A client whose first media range is text/markdown still gets the index, and every other path above is unchanged. Blank, or anything but an https URL, and / serves the landing page. Set it only once that page is live: the redirect does not check.

Keeping a private site private

cloudflare-docs-require-access: true makes the deploy ask Cloudflare which Access applications exist and refuse to publish unless one actually covers <host>/<repo>:

          cloudflare-docs-require-access: 'true'

"The site is behind Access" is otherwise a belief that nothing checks, falsified silently the day an application is renamed or its domain edited. This is the one moment something can ask.

The API token needs the Access: Apps read permission for this. Cloudflare's "Edit Cloudflare Workers" template does not include it, and a 403 is reported as could not tell rather than as "nothing covers it" — the two look alike in the response and collapsing them would publish a private site while reporting that it checked.

The repository's name also belongs in PRIVATE_SITES in the router's wrangler.toml before its [[services]] block lands. Access gates people, not the router's own reads over the binding, so without it the public landing page, /llms.txt and the sitemap index would list the site with the title and summary from its llms.txt.

The docs corpus

The build already holds every page it just rendered, so it emits a machine-readable copy of the site at no extra cost: llms.txt (a link index) and llms-full.txt (every page's text) are written into the site before it is published, and a search index of every page is generated beside it. On by default (cloudflare-docs-index), with no credentials and no network.

The corpus is what the build rendered, not everything under docs/. A file the build left out (exclude_docs, draft_docs) is not indexed, because its URL would 404; the step's log names each one.

llms.txt links each page's markdown twin when the metadata step above wrote one, as llmstxt.org asks, and the page itself otherwise. The search index always cites the page.

Name a bucket and a deploy also publishes that index to R2 as index/<repo>.json, which is the corpus the documentation MCP servers read:

          cloudflare-docs-index-bucket: magmamoose-docs-index

Only a deploy writes it, and only after the site itself deployed: there is one key per repository, so a pull request would otherwise overwrite the shared corpus with an unmerged branch, and an index published ahead of a failed deploy would cite pages nobody serves. The token needs R2 object write on top of the Workers permissions. The upload is not failure-isolated: a run that deployed the site and quietly failed to publish the index would be green while every agent read the previous commit's documentation.

The capability registry

The corpus answers questions about documentation. It cannot answer the one an agent asks before it writes a workflow — which house tool already does this, how do I consume it, and if none does, where do I file? Prose has to be interpreted, it does not carry the uses: ref, and it cannot say no: four pages read and nothing found is indistinguishable from a tool that has not published, and those two lead to opposite actions.

So a tool declares what it does, in a file at its own root, and this deploy ships it to capability/<repo>.json beside the index:

{
  "schema": 1,
  "repo": "tremvok",
  "private": false,
  "file_issues_at": "MagmaMoose/tremvok",
  "action": { "uses": "MagmaMoose/tremvok@v2", "kind": "composite-action" },
  "capabilities": [
    {
      "id": "cloudflare-docs",
      "summary": "Build an MkDocs site strictly and publish it to Cloudflare Workers.",
      "ecosystems": ["python", "mkdocs"],
      "inputs": ["cloudflare-docs-host", "cloudflare-docs-index-bucket"],
      "excludes": ["a docs site that is not MkDocs", "publishing to GitHub Pages"],
      "doc": "docs/setup.md"
    }
  ]
}

cloudflare-docs-capability-file names it, defaulting to capability.json; empty turns it off. Absent is the normal case — most repositories are not house tools — and a run that finds no file uploads nothing and says so in its summary.

excludes is the field that earns its keep, and the one worth writing first. A capability that only says what it covers gets returned for cases it cannot serve, and the symptom is a check that passes having measured nothing. A capability that declares none is warned about.

A declaration that does not validate fails the run, on a pull request as much as on a deploy. The schema is capability.schema.json, and the reason for refusing rather than uploading is what the MCP does with a broken document: it reads it as private and reports it as unreadable, counted and never named. A tool that vanished because its JSON broke looks exactly like a tool that declared nothing, from the only side that could notice. Every problem is reported in one run, not one per attempt.

Validation runs on a pull request; the upload does not. There is one key per repository, so a preview that wrote would overwrite the shared registry with an unmerged branch — but a declaration only checked on main is checked after the merge that broke it.

Two fields are the publisher's rather than the file's. commit and generated are stamped from the run, because a file in a repository cannot know which commit it shipped from. And private is republished from the repository's own visibility on the same fail-closed rule as the index: a declaration is public only when it asks to be and GitHub says the repository is public. Two visibility rules over one bucket is one rule that eventually disagrees with the other, and the direction it disagrees in is a leak.

cloudflare-workers

permissions: { contents: read, pull-requests: write }

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: npm run build                       # your build, not Tremvok's
      - uses: MagmaMoose/tremvok@v2
        with:
          target: cloudflare-workers
          artifact-path: dist                    # the Worker's asset directory
          cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          cloudflare-account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

Two secrets, and no GitHub permission for the deploy itself: Cloudflare doesn't need one, so pull-requests: write is there only for the sticky preview comment. Mint the API token from Cloudflare's "Edit Cloudflare Workers" template rather than a hand-picked permission list, or the first deploy of a custom domain fails on a permission nobody thought to grant. Pass the account id as a secret too.

Your wrangler.toml (or wrangler.jsonc) stays authoritative. It owns the asset directory, the routes, custom domains and 404 handling, so what ships matches what's reviewed in the repository. The inputs are overrides for the few things a workflow legitimately varies between runs: artifact-path is passed as --assets, cloudflare-worker-name as --name, cloudflare-env as --env, and cloudflare-config points at the file when it isn't where Wrangler would look.

The mode decides which Wrangler command runs:

Event Mode Wrangler
push to the default branch, or workflow_dispatch deploy wrangler deploy, live on the routes in your config
pull request preview wrangler versions upload --preview-alias pr-<number>

A preview takes no production traffic. versions upload uploads the version and gives it its own URL; it never moves the live routes, which a plain deploy would. The alias is the pull request number, so the link in the comment is stable across pushes. A branch name isn't: it changes, and it isn't always URL-safe.

Validating a pull request without publishing it

A preview still runs the pull request's code with the Worker's real bindings. For a Worker whose bindings reach data an unreviewed branch shouldn't run against (a private R2 bucket, a production database), don't preview: dry-run the pull request instead.

          dry-run: ${{ github.event_name == 'pull_request' }}

On this target a dry run is Wrangler's own wrangler deploy --dry-run: it bundles the Worker and validates its configuration, uploads nothing, and calls no API, so it needs no credentials. A fork's pull request, or a repository whose deploy token doesn't exist yet, can still prove the Worker builds. A dry run that doesn't bundle fails the job.

A binding is only real if Wrangler prints it

cloudflare-verify-config (on by default) runs that same dry run before every publish and refuses to publish when Wrangler reports configuration it won't apply:

  • an unexpected field. A misspelled [[r2_bucket]] is only a warning: Wrangler exits 0 and deploys a Worker with no bucket, and nothing fails until a request needs it;
  • a binding an --env deploy doesn't inherit, which Wrangler also only warns about.

The offending lines are printed. It costs one extra bundle per run; set cloudflare-verify-config: false to publish anyway.

Leave cloudflare-main empty for an assets-only Worker, which is the shape that serves files straight from the edge with no cold start, no code in the request path, and asset requests that aren't billed as invocations. Set it to your entry point for a Worker that runs code, and add cloudflare-build-command if that code needs bundling first.

Wrangler is pinned (cloudflare-wrangler-version, 4.114.0 by default), because the tool that publishes to production isn't a floating dependency. The action installs Node 24 for it: Wrangler 4 declares engines.node >= 22, and on 20 it installs cleanly and then refuses to run.

azure-functions-zip

permissions: { contents: read, id-token: write, pull-requests: write }

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-dotnet@v6
        with: { dotnet-version: '9.0.x' }
      - run: dotnet publish -c Release -f net9.0 -o publish
      - run: cd publish && zip -r -q ../package.zip .   # the CONTENTS, dotfiles included
      - uses: MagmaMoose/tremvok@v2
        with:
          target: azure-functions-zip
          artifact-path: package.zip
          azure-client-id: ${{ vars.AZURE_CLIENT_ID }}
          azure-tenant-id: ${{ vars.AZURE_TENANT_ID }}
          azure-subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
          functions-app-name: ${{ vars.FUNCTIONS_APP_NAME }}
          functions-resource-group: ${{ vars.FUNCTIONS_RESOURCE_GROUP }}

No publish profile. The Azure quickstarts hand you one, and it is a long-lived file carrying the deployment rights of the whole site with nothing tying it to a repository. azure-client-id is the alternative and the same argument as aws-role-to-assume: an Entra ID app registration with a federated credential naming this repository and ref, a session minted per run from the run's own OIDC token, and nothing at rest. That is what id-token: write is for. Create the credential against repo:<owner>/<repo>:ref:refs/heads/main (or an environment), and give the service principal Contributor on the Function App's resource group — or Website Contributor, which is narrower and enough.

A caller who would rather run azure/login in an earlier step can: leave azure-client-id empty and the action uses the session already on the runner.

Zip the contents of the publish directory, not the directory. cd publish && zip -r -q ../package.zip . — because zip -r package.zip publish nests everything one level down and zip -r ../package.zip * silently skips dotfiles. The worker reads functions.metadata and loads its extensions from .azurefunctions/, both of which must be at the archive root. Get this wrong and the package deploys perfectly cleanly and then serves nothing: no error, no log line, a 404 on every route. Tremvok refuses such a package rather than letting you discover it in production.

Pin the runtime to net9.0. DOTNET-ISOLATED|10.0 is offered by the platform and accepted by az functionapp create, and a Linux Consumption app on it never starts — the site and its SCM endpoint both return 503, with no log output at all. 9.0 started first try with an identical package.

The exit code is not evidence, and neither is platform state. az functionapp deployment source config-zip has been observed printing ERROR: Operation returned an invalid status 'Bad Request' and exiting non-zero over a deploy that succeeded. So a non-zero exit is not taken at face value: Tremvok asks the platform whether WEBSITE_RUN_FROM_PACKAGE actually moved, and fails only if it did not. Nor does it trust the resource: an azurerm_linux_function_app reports state: Running and availabilityState: Normal while returning 503. After publishing, the app has to answer, and the run fails if it never does. Any HTTP status counts, including the 404 a Function App returns at its root when its only trigger is at /api/<name>; a connection failure and a 503 do not. A freshly created Consumption app 503s from both the site and its SCM endpoint until content is first published, so a first deploy retries through that rather than failing on it — functions-ready-attempts × functions-ready-delay is the ceiling, five minutes by default.

verify-url sits on top of that and is where you assert what a particular route does: for a webhook receiver, an unsigned request getting 401 is the check worth having — and it needs verify-method: POST. The trigger binds POST and nothing else, so a GET reaches no function and Azure answers 404, which is also what a package containing no functions returns. Asserting POST401 is what distinguishes a working deploy from a broken one; a GET against such a route cannot.

A pull request publishes nothing, unless functions-slot is set. A slot is Azure's only destination that does not take production traffic, and a Linux Consumption plan has no slots, so on Consumption a preview validates the package and stops, saying why. On Premium or Dedicated, set functions-slot and previews go to the slot.

s3-cloudfront and lambda-zip

See examples/. Both need id-token: write for the role, and pull-requests: write for the sticky preview comment.

terragrunt

on:
  pull_request:
  pull_request_review: { types: [submitted, dismissed] }
  push: { branches: [main], paths: ['terraform/**'] }
  schedule: [{ cron: '0 5 * * 1-5' }]   # the weekday drift run

permissions:
  contents: read
  id-token: write
  pull-requests: write
  checks: write          # the check run that makes apply-before-merge enforceable

An independent pull-request approval is the apply authorisation. Approving applies the stacks, and the check run turns green once they are applied. terragrunt-apply-operators names who may force one by hand; empty means nobody, so that path fails closed.

Approving is what applies, and the run has to be the approval. A pull_request run that finds an approval already standing plans and reports rather than applying it: the approval was given for the commit it was given for, and a commit pushed after it has not been reviewed. The check run then reads Approved, but not applied for this commit. Dismiss the approval and re-approve to apply the commit in front of you. This is what pull_request_review is doing in the triggers above, and a workflow without it never applies at all. A review that is a comment or a change request is not an approval either, so it plans.

No AWS credential is required. Terragrunt takes its credentials from the backend and provider blocks in your own configuration, so an estate on Azure, GCP or a private cloud runs without aws-role-to-assume and without id-token: write; drop both from the block above if nothing in the run reaches AWS. Per-stack state credentials below is how a state-backend key reaches one stack and not the others.

The gate is the action's own (scripts/approval-gate.sh), so it needs no GitHub environment:. Add an environment: to your job only if you want what an environment adds beyond the gate: a wait timer, or secrets scoped to it.

Letting a merge apply what it merged

Off by default. A push to the default branch plans and applies nothing unless you ask for more:

with:
  target: terragrunt
  terragrunt-apply-on-merge: 'true'

A push event carries no pull request, and the approval that authorises an apply belongs to the pull request the commit was merged from. With this on, the run resolves that pull request from the commit (GET /repos/{repo}/commits/{sha}/pulls, so squash, merge-commit and rebase merges all resolve) and reads its reviews. Three outcomes, kept distinct on purpose:

On the default branch, with apply-on-merge on What happens
Merged from a pull request that had an independent approval the affected stacks are applied, and the plan comment on that pull request is rewritten to the result
Merged without one, or pushed directly plans and applies nothing, commenting on the merged pull request when there is one to comment on. The run exits 0 and the check run is neutral / Planned; not applied
The API could not be read, and there were pending changes nothing is applied and the run fails, with a failure check run published first. An API outage must never read as "nobody approved"

Where several merged pull requests are associated with one commit, the one whose base branch is the branch that was pushed wins; among the rest, the oldest merge does. Neither unreadable answer — the review list or the commit-to-pull-request lookup — refuses unless there was something to apply, so a merge where every stack plans clean is not turned red by an API blip. Both still warn, so a token missing pull-requests: read does not stay invisible until the first merge that changes something.

An unapproved merge is reported rather than failed: it is a branch-protection matter, not a broken build, and turning the default branch red does not fix it while leaving the stacks unapplied and invisible would. The scheduled drift run keeps reporting them, and terragrunt-apply: force applies them by hand.

terragrunt-apply-on-merge is deliberately not a value of terragrunt-apply. That input answers "who may authorise an apply?"; this one answers "should a merge commit apply at all?". With it on, the path needs pull-requests: read on the token, which the pull-requests: write above already covers.

Which stacks a change plans

A stack is a directory holding terragrunt.hcl. On a pull request or a push, the changed files decide which of them run:

The change The stacks it plans
A file inside a stack that stack, however deep the file sits inside it
A file above the stacks, such as a shared root.hcl every stack beneath that file's own directory, because every one of them includes it through find_in_parent_folders
A file directly in terragrunt-root every stack, for the same reason
A file under modules/ (or anything in terragrunt-exclude) none
A file outside terragrunt-root none

A module maps to nothing on purpose: it has no state of its own, and guessing which stacks use it from its path is how a small module tidy-up ends up planning the whole estate. The scheduled drift run covers it. Everything above is per changed path and the results are merged, so one pull request that edits a shared root and one stack plans that whole subtree once.

Failing fast on an unreachable endpoint

Terragrunt buffers plan output to a file, so a state backend or provider API the runner cannot reach is not an error: it is a silent wait until terragrunt-timeout with an empty log. terragrunt-preflight-urls probes each URL once, with an 8-second timeout, before the first plan:

with:
  target: terragrunt
  # A repository variable, so an unset one probes nothing and the block is safe to copy.
  terragrunt-preflight-urls: ${{ vars.TERRAGRUNT_PREFLIGHT_URLS }}

Any HTTP answer passes, 401 and 403 included: an unauthenticated probe of a credentialed endpoint is supposed to be refused, and being refused proves something is there. Only a curl code of 000 fails, which is DNS, connection refused, a connect timeout or a TLS failure. A 5xx warns and passes, so a transient 503 cannot make this a flake. When something is unreachable the step prints the runner's egress IP, which is the fact you need next if the endpoint is IP-allowlisted.

It proves reachability, not authorisation. A passing 403 does not mean your credential works. Blank lines and # comments are ignored, and these URLs are printed into the run log, so put nothing secret in them. A URL carrying userinfo (https://user:password@host/) is refused outright, and the refusal names the line by its index and shows the URL with the userinfo replaced, rather than echoing the line. Empty (the default) probes nothing.

Planning a named pull request by hand

terragrunt-pull-request points a manual run at one pull request: its reviews are what the approval gate reads, its thread is where the plan comment goes, and its head commit is what the check run is published against.

on:
  workflow_dispatch:
    inputs:
      pull_request:
        description: 'Pull request number to plan. Empty plans the default branch.'
        type: string
        default: ''

jobs:
  terragrunt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<pinned sha>  # v7.0.1
        with:
          fetch-depth: 0
          # GitHub's merge of the pull request into its base: what lands on the default
          # branch if it merges. Empty falls back to the ref the run started from.
          ref: ${{ inputs.pull_request && format('refs/pull/{0}/merge', inputs.pull_request) || '' }}

      - uses: MagmaMoose/tremvok@v2
        with:
          target: terragrunt
          # Not optional here. Without it the action checks the workflow ref out again and
          # the merge tree is gone.
          checkout: false
          terragrunt-pull-request: ${{ inputs.pull_request }}

Checking out the right tree is yours, not Tremvok's: the action never fetches a merge ref, it plans whatever is on disk. When the named pull request's head commit is not in that tree the run warns and carries on, because checkout: false with a partial tree is a legitimate choice. Two things fall out of this shape. You dispatch from the default branch and name the pull request by number, so the "a manual run must start from main" rule still passes with no exception. And refs/pull/<n>/merge does not exist while the pull request has conflicts, so actions/checkout fails with git's own message before Tremvok runs at all.

terragrunt-scope: auto means the stacks that pull request touches whenever a pull request is in scope, however it got there. all stays legal: plan the whole estate, gate on that pull request's approval, comment on that pull request. A fork pull request is refused on this path, exactly as the automatic one refuses fork code before it reaches a deploy credential.

Credentials for the providers, which are not the state backend's

The most confusing failure this target has, and the one worth setting up before the first run. A terragrunt run needs more than one credential, and they come from different places:

  • The state backend's, which terragrunt-stack-env supplies per stack — an ARM_ACCESS_KEY, a role, a key file. See the section below.
  • Every provider's, resolved by that provider's own chain, which nothing in the workflow mentions.

Supply the first and not the second and the run does not fail early or clearly. init reads and writes state perfectly well, the plan starts, and then every stack dies inside a provider:

Error: unable to build authorizer for Resource Manager API: could not configure AzureCli
Authorizer: tenant ID was not specified and the default tenant ID could not be determined:
obtaining tenant ID: obtaining account details: running Azure CLI: exit status 1:
ERROR: Please run 'az login' to setup account.

  with provider["registry.opentofu.org/hashicorp/azurerm"],
  on provider.tf line 25, in provider "azurerm":

Twenty times over, pointing at a provider.tf a generate block wrote and nobody has opened. That reads as a broken runner. It is a credential nobody wired.

Azure

The same three inputs as azure-functions-zip, and the same federated credential:

permissions: { contents: read, pull-requests: write, checks: write, id-token: write }

- uses: MagmaMoose/tremvok@v2
  with:
    target: terragrunt
    azure-client-id: ${{ vars.AZURE_CLIENT_ID }}
    azure-tenant-id: ${{ vars.AZURE_TENANT_ID }}
    azure-subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

The action signs in with this run's OIDC token before the first plan, and provider "azurerm" with no explicit auth picks that session up from its default chain. Give the app registration a federated credential for each subject the workflow runs under — repo:<owner>/<repo>:pull_request for the plan on a pull request, and repo:<owner>/<repo>:ref:refs/heads/<default-branch> for the apply, the post-merge run and the schedule — with audience api://AzureADTokenExchange.

On GitHub Enterprise Cloud with data residency the issuer is not https://token.actions.githubusercontent.com. It is https://token.actions.<your- subdomain>.ghe.com. Read /.well-known/openid-configuration at that host and use the issuer it returns; a federated credential built on the wrong one fails with a message that blames the token rather than the issuer.

AWS

aws-role-to-assume applies to this target too, and has since the target existed — the step is gated on the input, not on a target, so nothing extra is needed:

- uses: MagmaMoose/tremvok@v2
  with:
    target: terragrunt
    aws-role-to-assume: arn:aws:iam::123456789012:role/tremvok-terragrunt
    aws-region: eu-west-1

The assumed-role session is exported into the environment, so both the S3 backend and the aws provider find it in the default chain — one credential covers both, unlike Azure.

Google Cloud

- uses: MagmaMoose/tremvok@v2
  with:
    target: terragrunt
    gcp-workload-identity-provider: projects/123456/locations/global/workloadIdentityPools/github/providers/tremvok
    gcp-service-account: tremvok@my-project.iam.gserviceaccount.com   # optional
    gcp-project-id: my-project                                        # optional

The full provider resource name, not the pool — a pool name is refused before any call is made, because Google answers it with a 400 about an invalid audience that names nothing useful.

The action mints the run's OIDC token, writes it and a small external_account credential configuration into RUNNER_TEMP at 0600, and exports GOOGLE_APPLICATION_CREDENTIALS. The Terraform google provider reads that variable like every other Google client, and a GCS backend uses the same credentials.

gcp-service-account is optional. Leave it empty when the IAM bindings name the pool's principalSet directly; set it, and that principalSet needs roles/iam.workloadIdentityUser on the service account.

Both halves are proved before the run continues, because they fail alike from inside Terraform and have different fixes: an STS exchange that is refused means the pool provider's issuer or attribute condition does not match this repository or ref, and an impersonation that is refused means it does match and the workloadIdentityUser binding is missing. The error names which.

Anything else

Sign in during an earlier step, or hand the credential to the stacks that need it through terragrunt-stack-env. The action does not care which; it checks that something is there.

The check that says so in one line

terragrunt-credential-preflight reads the provider blocks of every discovered stack, and of every parent directory up to terragrunt-root so a shared root.hcl counts, and asks whether this runner holds a credential for each cloud they name. On auto, the default, a missing one fails the run before the first plan with the cloud, the stacks and the fix:

## Terragrunt — a provider has no credential

### azure — 19 stack(s)

set azure-client-id, azure-tenant-id and azure-subscription-id (the action signs in with
this run's OIDC token), run azure/login in an earlier step, or hand the stack ARM_CLIENT_ID
and a secret through terragrunt-stack-env. ARM_ACCESS_KEY is the state backend's credential
and does not configure the provider.

- `terraform/azure/non-prod/westerneurope/aks` (provider "azurerm")

It knows azurerm/azuread/azapi, aws, google/google-beta and vcd, and passes over a provider it does not recognise in silence rather than guessing. A provider block that configures its own authentication — client_id, credentials, api_token and the rest — is not checked, because that stack has answered the question itself.

It proves a credential is present, never that it is valid or that it reaches the subscription, project or account the stack names — the same line terragrunt-preflight-urls draws between reachable and authorised. warn annotates and plans anyway; off checks nothing.

Per-stack state credentials

If your production state lives in a different storage account from the rest, which is a deliberate blast-radius boundary rather than an accident, one credential can't reach both. terragrunt-stack-env applies environment per stack:

with:
  target: terragrunt
  terragrunt-stack-env: |
    */prd/*|*/prod/*  ARM_ACCESS_KEY=${{ secrets.PRD_STATE_KEY }}
    *                 ARM_ACCESS_KEY=${{ secrets.STATE_KEY }}

One <glob> KEY=VALUE per line. The first matching line wins for a given key, so the specific pattern goes above the catch-all, exactly as it would in a case. Blank lines and # comments are ignored, and a line with a pattern but no assignment fails the run rather than being skipped.

The values are secrets, so they're passed to each invocation with env rather than exported into the shell: one stack's credential never reaches the next stack's run. The apply gets the same environment the plan got, which matters more than it sounds. A plan that reads state with one credential and an apply that writes it with another is the worst version of this bug, because the plan looks fine.

ansible

permissions: { contents: read, pull-requests: write }

Runner-agnostic on purpose. A fleet reachable only from inside a private network needs a self-hosted runner that sits in it. That's your runs-on:, and the action does not check, because the same playbook against reachable hosts is a legitimate use.

Letting the playbook read its own Vault secrets

vault-addr and vault-token let the action resolve ansible-*-vault references. They live in the step's environment, and by default the action removes them before ansible-playbook starts: a token scoped to the fields Tremvok reads would otherwise be usable by every task, role and collection in the play, and nobody chose that.

ansible-vault-passthrough: true chooses it. VAULT_ADDR, VAULT_TOKEN and VAULT_NAMESPACE are then passed to the playbook, so it can read its own secrets from the same Vault instead of having them copied into a second store that quietly stops being rotated. The token is masked. Without both vault-addr and vault-token set, nothing is passed and the run says so. Nothing to do with ansible-vault the file-encryption tool; that is ansible-vault-password.

An IAM role the workflow can assume

For the three targets that can use an AWS role. s3-cloudfront and lambda-zip need one, and terragrunt needs one only when its own backend or providers reach AWS. Tremvok authenticates with this run's GitHub OIDC token; nothing is stored in the repository. The role's trust policy is what decides who may use it. Scope it to the repository and the refs that may deploy:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
    "StringLike": { "token.actions.githubusercontent.com:sub": "repo:my-org/my-site:ref:refs/heads/main" }
  }
}

StringLike on sub with a ref: prefix, not repo:owner/name:*. The wildcard form lets a pull request from a branch in the same repository assume a production deploy role.

Grant it only what the target needs. For s3-cloudfront that's s3:PutObject, s3:DeleteObject, s3:ListBucket on the one bucket, and cloudfront:CreateInvalidation on the one distribution.

Secrets for the ansible target

The SSH key and the vault password arrive as repository or organisation secrets, passed as inputs. Tremvok masks each on receipt, writes them to 0600 files under $RUNNER_TEMP, and removes them with a trap that fires however the step exits, nothing reaches a command line, where ps would show it.

with:
  target: ansible
  ansible-playbook: ansible/site.yml
  ansible-inventory: ansible/inventory/production
  ansible-ssh-private-key: ${{ secrets.ANSIBLE_SSH_KEY }}
  ansible-vault-password: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
  ansible-ssh-known-hosts: ${{ secrets.ANSIBLE_KNOWN_HOSTS }}

ansible-ssh-known-hosts is optional and omitting it disables host-key checking, which the run says out loud. Supply it for anything reachable from a network you do not control.

Reading them from HashiCorp Vault instead

If a secret already lives in Vault, name it by reference rather than copying it into a GitHub secret. A copy is a second thing to rotate, and the failure mode is silent: you rotate in Vault, the copy keeps working, and nobody finds out until it doesn't.

with:
  target: ansible
  ansible-playbook: ansible/site.yml
  ansible-inventory: ansible/inventory/production
  vault-addr: https://vault.example.com:8200
  vault-token: ${{ secrets.VAULT_TOKEN }}
  ansible-ssh-private-key-vault: secret/data/team/app#ssh_private_key

The reference is <path>#<field>. Each -vault input is the alternative to the literal one, never a supplement: setting ansible-ssh-private-key and ansible-ssh-private-key-vault together fails rather than quietly preferring one. ansible-ssh-known-hosts-vault and ansible-vault-password-vault work the same way.

Two different products called Vault

vault-addr and vault-token are HashiCorp Vault. ansible-vault-password is ansible-vault, the file-encryption tool, and has nothing to do with it. That's why the HashiCorp inputs aren't prefixed ansible-vault-: it would read as the wrong one.

KV v1 and v2 both work without you saying which: v2 nests the payload one level deeper, and both shapes are tried. If you're on v2 the path needs its /data/ segment (secret/data/team/app, not secret/team/app), and a 404 says so.

The token needs read on the paths you reference and nothing else. What comes back is masked and written to a 0600 file exactly like a literal secret, on the same single code path, and a failed read fails the run rather than continuing with no key.

(Optional) The Tremvok API

Only needed for deployment history, or for notifications that do not put a webhook URL in every repository.

Deploying it is described in terraform/README.md; the short version is that the module needs an artifact bucket, two SSM parameters written by hand, and allowed_owners set to the GitHub owners you actually control. Then add api-url: to the action and permissions: id-token: write. The repository stores nothing.

Verify it, properly

After the first deploy of any new wiring:

curl -si https://your-site/ | head -1        # the site answers

and for the API, a real signed POST, not a GET /healthz. A health check passing proves the function imported; it proves nothing at all about the write path, the table, or the IAM policy. The LocalStack smoke suite exists to make that distinction cheap to test.