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_descriptionon every page without adescription: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 keepssite_description, and when its title is the bare site name, the lead clause ofsite_descriptionjoins 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
WebSiteand its publisher on every page, and aTechArticleand aBreadcrumbListbuilt 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. Oncloudflare-docs,llms.txtlinks 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:
site_urlis the canonical address. Set it tohttps://<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.- A
wrangler.tomlat your repository root. Copyworkers/docs-site/wrangler.tomland setnametodocs-<repo>. It declares[assets]and, deliberately, no route and noworkers_devURL. - A
[[services]]block in the router's config, binding<REPO>todocs-<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
--envdeploy 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
POST → 401 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-envsupplies per stack — anARM_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.