') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); refactor!: rework deploy/renovate as composite actions with validated targets by ineedjet · Pull Request #162 · rubykatzen/flightdeck · GitHub
Skip to content

refactor!: rework deploy/renovate as composite actions with validated targets - #162

Merged
ineedjet merged 22 commits into
mainfrom
feat/renovate-folder-prototype
Aug 28, 2026
Merged

refactor!: rework deploy/renovate as composite actions with validated targets#162
ineedjet merged 22 commits into
mainfrom
feat/renovate-folder-prototype

Conversation

@ineedjet

@ineedjetineedjet commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

This branch has grown into a full reconsideration of how deploy/renovate are packaged.

deploy-shared.yml/renovate-shared.yml → composite actions ⚠️ breaking

Both were single-job workflow_call workflows — exactly the shape GitHub's own guidance says a composite action fits better than a reusable workflow (which earns its keep with multiple jobs or job-level config, neither ever used here). Converting eliminates two problems the workflow_call boundary forced on us:

  • The second checkout (repository: job.workflow_repository, ref: job.workflow_sha) needed purely to get this repo's own deploy.py/renovate.py onto the runner is gone — a composite action's own files come along automatically via $GITHUB_ACTION_PATH.
  • target-manifest-ref is gone with it — it only existed to keep that second checkout's ref in sync with whatever ref the caller had already resolved its matrix from.

deploy.py + siblings (collisions.py, render.py, resolve.py, vault.py) moved from deploy/ into .github/actions/deploy/ (a composite action's script must live inside its own action directory to be reachable via $GITHUB_ACTION_PATH). renovate.py moved into its own .github/actions/renovate/ — it never depended on deploy.py's sibling modules. deploy.yml/release.yml/renovate.yml's matrix jobs now checkout + resolve secrets themselves + call the action as one step, no more uses:/secrets: mapping into a separate workflow file. dupmachine/flightdeck needs updating to this contract too (on top of the earlier target-manifest change).

Notifications moved out of the actions, into the callers

Neither deploy nor renovate sends Telegram messages itself anymore. renovate exposes updated/updated-hosts/target-name as proper action outputs; renovate.yml reads them in its own separate "Notify Telegram" step, silent when nothing changed. deploy never had a "did anything change" concept to begin with, so deploy.yml/release.yml's own Notify Telegram step just runs unconditionally after a successful deploy step. A different caller can now wire up a different channel (or none) without forking either action.

Job naming cleanup

Every "load a directory into a matrix" job across deploy.yml/release.yml/renovate.yml is now consistently named load-targets/load-vaults, matching its own step id — deploy/encrypt stay named for what they actually do.

Also dropped --user --break-system-packages

Leftover from an old ansible-core install step, copy-pasted forward unchanged for years. The actions written later (encrypt-env, load-targets-matrix, load-vaults-matrix) already used the correct python3 -m pip install form, proven working on this runner without needing to override PEP 668 at all.

deploy.yml also has no target input anymore

It always redeploys every target under targets/ — a single-target workflow_dispatch input never actually got used, and dropping it makes deploy.yml and release.yml's own post-release deploy step functionally identical (same "always all targets" semantics).

release.yml now calls deploy.yml instead of duplicating it

Once deploy.yml lost its target input, its load-targets+deploy job pair became byte-for-byte the same thing release.yml needed after a release. release.yml's deploy job is now just:

deploy:
needs: [release, upload-apps, encrypt]if: needs.release.outputs.release_created == 'true'uses: $/.github/workflows/deploy.ymlsecrets: inherit

secrets: inherit's same-organization restriction isn't a concern here since both workflows live in this same repo — no org boundary is being crossed. deploy.yml also gained a bare workflow_call: {} (no inputs) purely so this works. One accepted trade-off: release.yml's deploy no longer mentions the release tag in its Telegram message, since deploy.yml's own generic "{target} updated" text doesn't know it — not worth reintroducing an input just to preserve that detail.

Earlier pieces already on this branch

  • Renovate prototype for Targeted single-app renovate: redeploy one app, not a whole target #121, now fully at parity with deploy's shape; apps input is a JSON array so one run can renovate several apps at once.
  • deploy/renovate contracts take a target-manifest path instead of flattened fields (matches encrypt-env's existing pattern for vault manifests).
  • Target manifest schema flattened: no more credentials: wrapper; Tailscale config moved out entirely (shared infra, not per-target); ssh_private_key_secret/sops_age_key_secret at the manifest root.
  • New load-targets-matrix (validates the schema above, no longer takes a name filter — always loads every target) alongside the renamed load-vaults-matrix (unvalidated, unchanged behavior, also no name filter, just named for what it actually loads).

Test plan

  • python3 -m unittest discover -s .github/actions/{deploy,renovate,load-targets-matrix,load-vaults-matrix}/tests — 85 tests pass total (55+11+12+7)
  • pre-commit (yamllint, pymarkdown, ruff, actionlint) — clean
  • Not yet run against the real heimdall target

@ineedjetineedjet changed the title feat: prototype a folder-driven renovate workflow (#121)feat: prototype renovate + adopt manifest-path contract for deploy-shared.ymlAug 27, 2026
@ineedjetineedjet changed the title feat: prototype renovate + adopt manifest-path contract for deploy-shared.ymlfeat: renovate prototype + manifest-path contract + validated target schemaAug 28, 2026
@ineedjetineedjet changed the title feat: renovate prototype + manifest-path contract + validated target schemarefactor: deploy/renovate as composite actions, validated target schemaAug 28, 2026
Deliberately a different input contract from deploy-shared.yml's
per-target matrix, to compare the two before picking one: renovate.yml
takes an app name and a targets directory, and deploy/renovate.py itself
finds which targets currently run that app and re-pulls/recreates its
containers there - no app_refs/env_refs re-resolution, no release
rebuild, no vault decryption. Known gap: no Tailscale support yet.
…flow
Match deploy.yml/deploy-shared.yml's two-file shape - renovate.yml is
now just a workflow_dispatch trigger, and all the logic (checkout,
running deploy/renovate.py) lives in renovate-shared.yml, called with
secrets: inherit since it resolves secrets for matched targets itself
rather than per-matrix-cell like deploy-shared.yml does.
Keep load-yaml-matrix a dumb, generic directory-to-matrix reader (no
app-filtering added to it) - instead renovate-shared.yml gets a second
job, mirroring deploy.yml/deploy-shared.yml's split: find-targets loads
every target unfiltered, then renovate fans out over that matrix one job
per target, with native per-matrix-cell secret/Tailscale resolution.
Every target gets dispatched regardless of whether it runs the requested
app; deploy/renovate.py itself decides to no-op when its one target's
apps mapping doesn't contain it. This drops the directory-reading and
GITHUB_SECRETS_JSON-parsing deploy/renovate.py used to do - it's now
scoped to one already-resolved target, same shape as deploy/deploy.py.
deploy/renovate.py now compares `docker compose images -q` before/after
the pull per host, so it can tell an actual update from a no-op pull,
and reports updated/updated_hosts via $GITHUB_OUTPUT. renovate-shared.yml
sends a Telegram message (via baseline's generic send-telegram-message
action, same one the existing notify-telegram-*.yml workflows use) only
when a host's image changed - silent on every no-op target-matrix cell.
…: inherit
renovate.yml now computes the target matrix itself and resolves each
matched target's secrets by name (secrets[matrix.credentials.secrets.X]),
exactly like deploy.yml/deploy-shared.yml - renovate-shared.yml goes back
to a single job with explicitly declared workflow_call secrets, so it
only ever sees the one target's own resolved secret values, never the
full secrets context.
Dropped an earlier attempt to skip dispatching to a non-matching target
via a matrix-conditioned job `if:` - actionlint (correctly) rejects
`matrix` in the `if:` of a job that calls a reusable workflow via `uses:`,
only github/inputs/needs/vars are available there. deploy/renovate.py's
own `app in apps` check already makes a non-matching target a cheap
no-op before any SSH connection happens, so this is a non-issue.
…ields
renovate.yml no longer exposes targets-directory as a dispatch input
(hardcoded to "targets", matching deploy.yml's own convention) and its
find-targets step id is now load-targets instead of the generic "matrix".
The bigger change: instead of flattening a target's hosts/apps/path into
separate with: inputs (each re-serialized via toJson(matrix.X)),
renovate.yml now just passes target-manifest: ${{ matrix.manifest }} -
the file path load-yaml-matrix already puts in every matrix item - and
renovate-shared.yml reads that file itself, the same way encrypt-env
takes a vault manifest path instead of flattened env fields. This drops
the with: block from 6 fields to 3 (app, target-manifest, tailscale
client id); the secrets: block is unchanged since secret values can only
ever come from an explicit named pass-through, never a re-read file.
renovate-shared.yml gets a second checkout (the caller's own repo, to
read that manifest) alongside the existing override checkout (flightdeck's
own code, now under .flightdeck/). deploy/renovate.py now takes
{app, target_manifest} on stdin and parses the YAML itself instead of
{app, hosts, path, apps} - it also derives and outputs target_name from
the manifest's filename, so the Telegram message no longer needs a
separate target-name input either.
…elds
Apply the same simplification renovate-shared.yml just adopted, now that
the only real external caller (dupmachine/flightdeck) is also ours to
update: deploy-shared.yml's public contract changes from flattened
hosts/app-refs/apps/path/keep-releases inputs to a single target-manifest
path, read and parsed by the workflow itself - the same shape encrypt-env
already uses for vault manifests. Adds a second checkout (the caller's
own repo, to read that file) alongside the existing override checkout of
flightdeck's own code, now under .flightdeck/.
Also adds target-manifest-ref: release.yml pins deploy-targets' own
checkout to the just-published tag, so deploy-shared.yml's separate read
of the same manifest file needs to agree on that exact ref rather than
silently reading whatever the default branch's tip is by the time it runs.
release.yml's deploy job now also lists release in its own needs:, since
referencing needs.release.outputs requires a direct (not just transitive)
dependency.
deploy.py's main() now reads {target_manifest, sops_age_key} from stdin
and parses the YAML itself via a new load_target() - validate_config and
everything downstream is unchanged, since they already just consumed a
plain dict shaped this way regardless of where it came from.
deploy.yml also drops the "redeploy every target" (target: all) mode -
target is now a required input, no default - and renames its
find-targets step id from the generic "matrix" to "load-targets",
matching renovate.yml's own naming.
…trix
Target manifests drop the credentials: wrapper entirely. Tailscale
config moves out of targets/*.yml altogether - it's one shared tailnet,
not per-target - so deploy.yml/release.yml/renovate.yml now reference
vars.TAILSCALE_OAUTH_CLIENT_ID/secrets.TAILSCALE_OAUTH_SECRET directly
instead of resolving a per-target name for the same fixed values.
ssh_private_key/sops_age_key move to the manifest root, renamed to
ssh_private_key_secret/sops_age_key_secret - the _secret suffix makes it
unambiguous these are GitHub Secret names, not values, now that they're
no longer nested under credentials.secrets. This also sidesteps a real
naming collision in deploy.py, which already uses the bare key
"sops_age_key" internally for the actual decrypted value.
New .github/actions/load-targets-matrix, a specialized sibling to
load-yaml-matrix used only for targets/: same directory+name matrix
contract, but validates hosts/app_refs/apps/ssh_private_key_secret/
sops_age_key_secret before ever producing a matrix item, so a broken
target manifest fails immediately with a clear error instead of
surfacing deep inside deploy.py/renovate.py after a checkout and
dependency install on a different job. load-yaml-matrix itself is
untouched and keeps validating nothing - it's still the right tool for
vaults/, where the caller never touches manifest fields directly and
encrypt-env already validates the same content one job later anyway.
deploy.yml/release.yml/renovate.yml switch their targets/ loading to
load-targets-matrix (dropping directory: targets, now the default) and
simplify their with:/secrets: blocks to the flat field names.
Renamed app -> apps throughout (renovate.yml, renovate-shared.yml,
deploy/renovate.py's stdin contract) so one run can target several apps
at once - e.g. a future nightly cron renovating "traefik,rybbit" while
leaving everything else alone - instead of one app per invocation.
deploy/renovate.py splits the comma-separated list, renovates whichever
requested apps are actually keys in the target's own apps mapping (still
a clean no-op if none match), and loops hosts inside that. updated_hosts
now reports app@host pairs instead of bare hosts, since more than one
app can be renovated in the same run.
…ated
Matches the JSON convention used everywhere else in this codebase
(hosts, app_refs, apps mappings) rather than inventing a one-off
comma-separated format just for this input. renovate-shared.yml passes
it through with --argjson instead of --arg, and deploy/renovate.py reads
config["apps"] directly as the already-parsed list.
…ectory
It's had exactly one consumer since load-targets-matrix took over
targets/ (release.yml's encrypt-vaults job) - naming it for what it
actually loads and defaulting directory to vaults lets that call site
drop the with: block entirely. No behavior change: still no schema
validation, encrypt-env re-parses and validates each manifest itself.
Test fixtures switched from generic hawkeye/mainframe examples to
vault-shaped ones (asset/keys/env) to match what this action actually
reads now that it's no longer shared with targets/.
…te actions
Both were single-job workflow_call workflows - exactly the shape GitHub's
own guidance says a composite action fits better than a reusable workflow
(which earns its keep with multiple jobs or job-level config, neither of
which these ever used). This also directly eliminates two problems the
workflow_call boundary forced on us:
- The second checkout (repository: job.workflow_repository, ref:
job.workflow_sha) needed purely to get this repo's own deploy.py/
renovate.py onto the runner is gone - a composite action's own files
come along automatically via $GITHUB_ACTION_PATH whenever it's
referenced as owner/repo/.github/actions/X@ref.
- target-manifest-ref is gone with it - it only existed to keep that
second checkout's ref in sync with whatever ref the caller had already
resolved its matrix from. With one checkout instead of two, there's
nothing to keep in sync.
deploy.py and its sibling modules (collisions.py, render.py, resolve.py,
vault.py) move from deploy/ into .github/actions/deploy/, since a
composite action's script has to live inside the action's own directory
tree to be reachable via $GITHUB_ACTION_PATH; deploy/tests/ moves the
same way. renovate.py and its tests move into .github/actions/renovate/
- it already had zero dependency on deploy.py's sibling modules, so it
becomes its own action rather than living alongside deploy's.
deploy.yml/release.yml/renovate.yml's matrix jobs now do their own
actions/checkout, resolve secrets themselves, and call the action
directly as one step among their own - no more uses:/secrets: mapping
into a separate workflow file. No script logic changed, only where the
files live and how they're invoked; all 89 tests pass unchanged in their
new locations.
Matches its step id (already load-targets) and deploy.yml/release.yml's
own job naming for the equivalent step (deploy-targets loads targets/
too, just paired with a different verb since it goes on to deploy them).
…ease.yml
Matches its step id (already load-targets) and renovate.yml's own
equivalent job, now that "deploy" as a verb belongs to the job that
actually deploys, not the one that just loads the target matrix.
Matches load-targets: the job's own work is loading vaults/ into a
matrix, not encrypting anything - that's the separate encrypt job that
consumes it. Step id renamed from the generic "matrix" to load-vaults
to match, same as load-targets-matrix's own step id.
…pip install
Leftover from an old ansible-core install step, copy-pasted forward
unchanged ever since - the actions written later (encrypt-env,
load-targets-matrix, load-vaults-matrix) already use the correct form,
`python3 -m pip install`, which targets the exact interpreter that later
runs the script rather than whatever bare `pip` resolves to. That's
already proven to work on this runner without needing to override
PEP 668's externally-managed-environment protection at all.
The action now only reports what happened (updated/updated-hosts/
target-name as proper action outputs) instead of hard-requiring
telegram-bot-token/telegram-chat-id and sending the message itself.
Notifying is renovate.yml's own job now, as a separate step reading
those outputs - matches deploy, which never had any notification logic
of its own, and means a different caller can wire up a different
channel (or none) without forking this action.
Same pattern just established for renovate: notification lives in the
caller's own job, not baked into the deploy action. Unlike renovate,
there's no "did anything change" gate needed - a deploy either fully
lands or the job fails outright, so the Notify Telegram step runs
unconditionally right after the deploy step, same as any other step
that simply never executes if the one before it failed.
release.yml's version also includes the release tag that was just
deployed, since it already has that on hand from the release job.
…lter
Single-target selection had exactly one consumer (deploy.yml's manual
redeploy) and this repo has exactly one target with no second one
planned - there was nothing left to disambiguate. deploy.yml now just
redeploys everything, matching what release.yml's own deploy path
already does; load-targets-matrix/load-vaults-matrix both drop the
name input and its --name/selected filtering logic entirely, since
neither has any remaining caller passing it.
All 85 tests across deploy/renovate/load-targets-matrix/load-vaults-
matrix pass unchanged in behavior for the "load everything" path -
only the removed filtering path and its tests are gone.
@ineedjet
ineedjetforce-pushed the feat/renovate-folder-prototype branch from 023b5ea to 4797067CompareAugust 28, 2026 22:42
deploy.yml lost its target input in an earlier commit - it now always
redeploys every target, exactly matching what release.yml's own
load-targets+deploy job pair already did. That made the two genuinely
identical operations, not just similar ones, so the duplication is worth
removing: deploy.yml adds workflow_call (no inputs, same as its
workflow_dispatch trigger) and release.yml calls it directly with
secrets: inherit instead of running its own copy.
secrets: inherit is fine here specifically because both workflows live
in this same repository - deploy.yml already has native access to every
one of this repo's own secrets when triggered directly, so inheriting
them from release.yml doesn't broaden anything. No organization boundary
is being crossed, unlike the renovate/dupmachine case that ruled out
inherit there.
Default (unpinned) checkouts inside deploy.yml's own jobs now resolve
correctly even when called from release.yml's push-triggered run,
because github.sha for that run already IS the commit release-please
just tagged - no target-manifest-ref equivalent needed.
Trade-off: release.yml's deploy no longer includes the release tag in
its Telegram message, since deploy.yml's own generic message doesn't
know it. Not worth an extra input and a conditional format(...)
expression to preserve that one detail.
@ineedjetineedjet changed the title refactor: deploy/renovate as composite actions, validated target schemarefactor!: rework deploy/renovate as composite actions with validated targetsAug 28, 2026
renovate.yml now also triggers on `schedule: 0 3 * * *`. A cron trigger
can't supply workflow_dispatch inputs, so `apps` becomes optional
(default "[]") both on the workflow input and the renovate action's own
input, and an empty apps array now means "every app this target runs"
instead of "no apps requested" - renovate.py already skipped apps not
present on a given target, so this just changes what an empty selection
means rather than adding new matching logic.
Mirrors load-targets-matrix's own validate_target: just type + non-empty
checks on asset/keys/env, so a structurally broken vault manifest fails
the matrix build immediately instead of reaching a checkout+dependency
install on a different job (encrypt-env) before erroring out.
encrypt-env's render-env.py still does the real field-level validation
(name patterns, source references) - this only catches missing/empty
top-level sections early, same division of labor as targets already had.
@ineedjet
ineedjet merged commit cfd3cb7 into mainAug 28, 2026
4 checks passed
@ineedjet
ineedjet deleted the feat/renovate-folder-prototype branch August 28, 2026 23:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ineedjet