Skip to content

feat: deploy rybbit for rubykatzen.com through flightdeck itself - #102

Merged
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit
Aug 20, 2026
Merged

feat: deploy rybbit for rubykatzen.com through flightdeck itself#102
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit

Conversation

@ineedjet

@ineedjetineedjet commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Flightdeck now uses its own release and deployment tooling to manage Rybbit for rubykatzen.com on the existing hawkeye server.

The final configuration model separates two independent concerns:

  • vaults/ describes encrypted env assets.
  • targets/ describes deployments and explicitly selects a machinery bundle, app bundles, an env asset, and SSH destinations.

The matching hawkeye.yml filenames are a convention only. The actual relationship is the explicit env_ref in the deployment target, so either collection can live in a repository without the other.

Flightdeck's own apps/ catalog is published as its own release asset (flightdeck-apps.zip), merged into a deploy the same way as any other app bundle. There's no special-cased "core" bundle that apps ride along with implicitly — a target must list every app bundle it wants, flightdeck's own included.

Configuration

vaults/hawkeye.yml defines:

  • the hawkeye.sops.env release asset;
  • the hawkeye age recipient;
  • the plain app list (traefik, rybbit), rendered as APPS=traefik,rybbit;
  • mappings from dotenv names to GitHub Secrets or Variables.

targets/hawkeye.yml defines:

  • machinery bundle: rubykatzen/flightdeck@latest;
  • app bundles (app_refs, required, non-empty): rubykatzen/flightdeck@latest;
  • env asset: rubykatzen/flightdeck@latest:hawkeye.sops.env;
  • SSH destination: rubykatzen-com@100.75.50.2;
  • names of the GitHub credentials needed for SSH and optional Tailscale access.

Credential values are never stored in target files.

Automation

  • Added build-bundle, which builds and uploads a bundle to an existing release; paths/bundle-name default to Flightdeck's own machinery bundle (flightdeck.zip) but are fully overridable.
  • Added build-apps-bundle, a thin defaults wrapper around build-bundle (paths: apps, bundle-name: flightdeck-apps.zip) used both for flightdeck's own apps/ catalog and any consumer repo's own app bundle.
  • Replaced publish-sops-env with encrypt-env, which renders a flat config from a vaults/ manifest, encrypts it with SOPS age recipients, and uploads it to an existing release.
  • Added load-yaml-matrix, a schema-agnostic action that reads every YAML file in a caller-given directory into a GitHub Actions matrix (no directory default, no validation — callers own their manifest shape).
  • Removed the unused manifest discovery action and the asset-oriented reusable workflows; release jobs call the composite actions directly.
  • Renamed the Release Please workflow to release.yml. A created release uploads flightdeck.zip and flightdeck-apps.zip, processes every config in vaults/, and deploys every config in targets/ — all gated on release_created, so an ordinary merge to main that doesn't cut a release does nothing.
  • Added deploy.yml for manual redeploys only (workflow_dispatch, select a target or "all"). It is deliberately not triggered automatically — an earlier version fired on every completed Release workflow run regardless of whether a release was actually created, which would have redeployed hawkeye (and bounced traefik/rybbit) on every ordinary merge to main.

Deployment Interface

deploy-shared.yml exposes deployment vocabulary rather than Ansible implementation details:

  • hosts are JSON arrays of user@host SSH destinations;
  • the machinery bundle, env asset, and app bundles use complete owner/repo@tag[:asset] refs; app-refs is a required, non-empty JSON array;
  • path, retention, and the SOPS age key path have defaults and remain overridable;
  • user@host values are converted to an internal Ansible JSON inventory;
  • ~ paths are expanded per SSH user on each target host.

The default deployment path is ~/flightdeck. The default age key path is ~/.config/sops/age/keys.txt, matching the normal Linux SOPS location for the connecting user while still working under Ansible privilege escalation.

External Prerequisites

  • GitHub Secret DEPLOY_SSH_PRIVATE_KEY.
  • GitHub Variable TAILSCALE_OAUTH_CLIENT_ID and Secret TAILSCALE_OAUTH_SECRET when Tailscale is used.
  • All GitHub Secret/Variable names referenced by vaults/hawkeye.yml.
  • The server-side rubykatzen-com user, corresponding SSH access and age private key, Docker, GitHub CLI, SOPS, and the external Cloudflare Tunnel configuration.

Verification

  • 9 load-yaml-matrix unit tests
  • 8 encrypt-env unit tests
  • Actual encrypt and deploy matrix generation, traced against the real vaults/hawkeye.yml and targets/hawkeye.yml
  • Generated inventory parsed by ansible-inventory
  • ansible-playbook --syntax-check ansible/deploy.yml
  • pre-commit run --all-files
  • GitHub Embedder, GitHub config, and lint checks
  • Publish the first real Hawkeye env asset after external prerequisites are configured
  • Run the first real Hawkeye deployment

ineedjetand others added 30 commits August 18, 2026 21:27
First real (non-CI-test) self-consumption of deploy-shared.yml:
flightdeck deploys its own rybbit instance at rybbit.rubykatzen.com,
via an isolated rubykatzen-com user on the existing hawkeye host
(Tailscale IP 100.75.50.2), separate from dupmachine's own services
on that box (own traefik on 8080/8443 to avoid the port conflict,
own age keypair, own env manifest).
- projects/rubykatzen/rubykatzen-com.yml: publish-sops-env manifest
(APPS=traefik,rybbit, Cloudflare DNS challenge cert resolver).
- keys/rubykatzen-com.pub: age recipient for this deployment's
encrypted env. Private key generated locally, not committed --
goes on the server at flightdeck_sops_age_key_file, out of band.
- publish-rubykatzen-com-env.yml: renders + encrypts the manifest,
publishes it to the rubykatzen-com-config release tag.
- deploy-rubykatzen-com.yml: calls deploy-shared.yml@v0.3.0 (pinned,
same as a real external consumer would) against 100.75.50.2.
Blocked on, not part of this diff: DEPLOY_SSH_PRIVATE_KEY secret,
TAILSCALE_OAUTH_CLIENT_ID/SECRET, RUBYKATZEN_COM_CLOUDFLARE_TOKEN
secret, and server-side prep (create rubykatzen-com user, install the
age private key, cloudflared tunnel). workflow_dispatch-only until
those land.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wkeye.yml
The manifest identity is the server + isolated user + age key it
decrypts on (hawkeye), not the domain/service currently running
there (rubykatzen.com/rybbit) or a repo-grouping level that doesn't
apply to self-hosting flightdeck's own deploys. Matches the existing
convention elsewhere of naming manifests/assets after the server
(mainframe, hawkeye), and drops the redundant projects/rubykatzen/
nesting -- one flat targets/ directory, one file per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the targets/ rename: the age key, release tag, and
asset identify the deploy target (server + isolated user + key), not
the service currently running there. Renamed rubykatzen-com.pub ->
hawkeye.pub, rubykatzen-com-config -> hawkeye-config,
rubykatzen-com.sops.env -> hawkeye.sops.env. The RUBYKATZEN_COM_*
GitHub variable/secret names are untouched -- those describe the
service's own config content, not the target identity, and the
rubykatzen-com OS username/home path on the server is a separate,
correctly-still-rubykatzen-com concept.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
We were the odd one out: deploy-rubykatzen-com.yml already calls
deploy-shared.yml as a real interface, but publish-rubykatzen-com-env.yml
hand-assembled its own "ensure release exists" step around the local
publish-sops-env composite action instead of going through an
equivalent reusable workflow -- because that reusable workflow didn't
exist yet, only the composite action did.
Add publish-sops-env-shared.yml (checkout, resolve release tag --
input or read from the manifest's own release_tag, ensure the release
exists, call publish-sops-env) and rewrite our own
publish-rubykatzen-com-env.yml to just call it, matching the same
consumer interface pattern deploy-rubykatzen-com.yml already uses.
Note: this one needs `secrets: inherit` on the caller, unlike
deploy-shared.yml's explicit named secrets -- the manifest can
reference an arbitrary, per-target set of secret/variable names, so
there's no fixed schema to declare individually.
Pinned to @main for now since this file doesn't exist in any released
tag yet; re-pin to a real version once the next release ships, same
as deploy-rubykatzen-com.yml does for deploy-shared.yml@v0.3.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an interface
The old interface leaked Ansible directly to callers -- extra-vars
was a hand-written JSON blob using flightdeck_* variable names lifted
straight from ansible/deploy.yml. Callers shouldn't need to know this
runs on Ansible at all.
Replace with named inputs in plain deploy vocabulary: app-ref,
env-ref, extra-refs (comma-separated), path, keep-releases,
sops-age-key-file. The workflow builds the ansible -e JSON internally
via jq. No behavior change for existing values, just the surface.
Added defaults for the ones that have a sensible one:
- path: ~/flightdeck (matches every example/consumer so far)
- keep-releases: 5
- sops-age-key-file: ~/.config/sops/age/keys.txt for the connecting
`user`, computed rather than hardcoded since it depends on which
user connects -- can't be a static workflow_call default.
We have no external consumers of this interface yet to break, so
doing this now rather than carrying the leaky version forward.
Also updates our own deploy-rubykatzen-com.yml to the new interface
(drops explicit path/keep-releases/sops-age-key-file entirely since
the new defaults already match what we were passing), and README's
deploy-shared.yml documentation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the earlier targets/keys/release-tag rename: these
workflows deploy a whole target's APPS list, not one specific
service. If hawkeye ever hosts a second app alongside rybbit, it's
the same workflow, same target -- naming it after rubykatzen.com
(the service currently there) would become misleading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One deploy entry point for the whole repo instead of one workflow
file per target. Each target is its own job (currently just
hawkeye); a workflow_dispatch `target` input picks which job(s) run
-- "all" (default) runs every target, a specific name runs just that
job. This is also the pattern we want client repos to copy: a system
that knows exactly which target it just pushed config for can
`gh workflow run deploy.yml -f target=<name>` instead of redeploying
the whole fleet.
No scheduling added yet -- that depends on the idempotent
reconciliation work in #100/#101 landing first, otherwise a cron
trigger would just force-restart everything on every tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fter it
- Rename release-please.yml -> release.yml (name: Release Please ->
Release), matching the file.
- deploy.yml now also fires on workflow_run of Release (in addition
to manual workflow_dispatch). Auto-redeploys hawkeye whenever
Release completes successfully -- including no-op runs where
release-please found nothing to release, since workflow_run
doesn't expose that workflow's own job outputs. Accepted as an MVP
tradeoff: harmless once deploy.sh is idempotent (#100), just an
unnecessary restart until then.
- Fixed a real bug this surfaced: deploy-shared.yml's app-ref input
was reused as both the checkout ref (needs a real git ref: branch,
tag, SHA) and the flightdeck_app_ref bundle to deploy (can be
'latest', a GitHub Releases API concept, not a git ref). Passing
app-ref: latest previously would have broken the checkout step.
Split "Resolve checkout ref" (always derived from workflow_ref,
used only for actions/checkout) from app-ref resolution inside
"Build extra-vars" (input override, defaulting to the checkout ref
if not given). deploy.yml's hawkeye job now passes app-ref: latest
so it always deploys the newest release regardless of which ref
runs the playbook mechanism itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No external consumers yet, so no reason to carry the extra branch:
"if app-ref is empty, reuse whatever ref runs the playbook mechanism"
was solving a DRY concern (avoid writing the version twice) at the
cost of conflating two genuinely different things -- which ref checks
out ansible/deploy.yml vs which release gets deployed. Now that we
need them to diverge (app-ref: latest while pinned to a stable
mechanism version), the implicit default is just a trap. Require
app-ref explicitly instead; deploy.yml already passes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml's upload job was hand-assembled from checkout +
build-bundle + gh release upload steps directly, the same pattern we
already moved away from for publish-sops-env. Extract it into a
generic reusable workflow: checkout a ref, build a zip via the
build-bundle composite action, upload it to an existing release.
release.yml's own upload job now just calls it via a local path
(./.github/workflows/upload-bundle-shared.yml) -- same-repo
self-reference, no ref-pinning concern since it always runs from
whatever commit release.yml itself runs from.
Also generic enough for external use: a consumer repo packaging its
own apps/ directory as an extra bundle for flightdeck_extra_refs can
call it the same way (checkout defaults to the caller's own repo,
same as any reusable workflow without an explicit repository:
override).
Docs: added README sections for both upload-bundle-shared.yml and
publish-sops-env-shared.yml (the latter existed but was never
documented), updated the GitHub Actions intro sentence to list all
three reusable workflows, and fixed a stale release-please.yml
filename left over in the project tree from the earlier rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f upload-apps-shared.yml
upload-bundle-shared.yml took bundle-name/paths as caller inputs, but
in practice there's exactly one caller (this repo's own release.yml)
with fixed, well-known content -- flightdeck.zip is core scripts +
apps/ + README, not something that varies per call. Generic
parameterization here just pushed config a consumer shouldn't need to
know onto the call site. Baked it in; the workflow now only takes
ref/release-tag.
Split the actual generic use case -- a consumer repo bundling its own
apps/ as an extra bundle for flightdeck_extra_refs -- into its own
purpose-built upload-apps-shared.yml (fixed to apps.zip / apps/ only).
Added a comment at release.yml's upload job pointing consumers at it,
so reading our own release flow makes the intended split obvious
rather than inviting a copy-paste of flightdeck-specific config.
Also: switched every local composite-action reference inside these
shared workflows (publish-sops-env-shared.yml, upload-bundle-shared.yml,
upload-apps-shared.yml) from ./.github/actions/x to $/.github/actions/x
-- GitHub's new (July 2026) self-repository syntax. ./ inside a
reusable workflow resolves against the CALLER's repo, which is wrong
here and would have broken the moment an external repo actually called
publish-sops-env-shared.yml or upload-apps-shared.yml (our own usage
so far has been same-repo, so this was latent). $/ always resolves
against the workflow's own defining repo at the exact ref running,
regardless of caller -- matches the pattern rubykatzen/baseline
already uses in lint-shared.yml.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… a separate trigger
publish-hawkeye-env.yml had its own independent trigger (push when
targets/hawkeye.yml or keys/hawkeye.pub changed), decoupled from
whether a release actually happened. Config changes for a target are
conceptually part of the release cycle here, same as the bundle --
fold it into release.yml as another job gated on release_created,
same shape as upload. Delete the standalone workflow; a per-target
manual/path-based publish trigger isn't needed once every target's
env publish just rides along with the release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the publish-hawkeye-env.yml removal in
2b7b16a -- this is the actual job that replaces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Predates the explicit per-target job pattern (targets/hawkeye.yml,
named jobs in release.yml/deploy.yml) this repo now actually uses --
it was built for a dynamic glob-based matrix discovery approach we
never ended up using here, and nothing in this repo calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the action removal in d1472bb.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build-bundle was the odd one out -- publish-sops-env already does
render+encrypt+upload as one action, but build-bundle only built the
zip and left the gh release upload as a separate step in each caller.
Fold upload into build-bundle itself (new release-tag/token inputs),
so upload-bundle-shared.yml and upload-apps-shared.yml both drop to
checkout + one action call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deploy.yml previously fired on every completed "Release" workflow_run,
including runs where release-please created no release - which meant
every ordinary merge to main would redeploy hawkeye and bounce
traefik/rybbit. Move the automatic deploy into release.yml, gated on
release_created like upload/targets/encrypt, and leave deploy.yml with
only the manual workflow_dispatch redeploy path.
"targets" already means deploy destinations (servers). Reusing that
name for the generic config-matrix reader used by both encrypt and
deploy modes was confusing. Rename the action to load-matrix, its
name-selector input/flag from target to name, and the matrix item's
target field to name. Also rename release.yml's encrypt-mode job from
targets to encrypt-configs so it doesn't collide semantically with the
new deploy-targets job.
Rename load-matrix to load-yaml-matrix and strip out its encrypt/deploy
schema knowledge. It now just reads every YAML file in a directory,
merges each file's top-level fields with name/manifest, and builds a
matrix - no mode, no field validation, no defaults.
encrypt-env already re-parses and validates its own manifest from the
file path, so the encrypt/ path loses nothing. The targets/ path had
no other validator, so move what it relied on into the callers:
release.yml and deploy.yml now apply the path/keep-releases/sops-age-
key-file defaults inline and read credentials.secrets/credentials.variables
directly off the raw matrix item instead of the flattened fields
load-matrix used to produce.
This drops schema validation entirely (SSH destination format, ref
format, duplicate hosts, unknown-key typos) - a malformed targets/*.yml
now only surfaces once deploy-shared.yml or ansible/deploy.yml actually
runs against production hosts, not before.
targets/ is a plural noun naming what it holds (deploy targets).
encrypt/ was a singular verb, inconsistent with that pattern and with
its own contents (env asset configs, one per encrypt-env manifest).
Rename to envs/ to match.
envs/ read unnaturally next to targets/ and keys/. sops/ matches the
asset naming convention already used throughout (*.sops.env) and pairs
directly with keys/, which already holds the age recipients used to
encrypt into that directory's assets.
One repo prepares vaults from a pool of secrets/vars; different
consumers (targets) unpack the vault meant for them. Also just a
better English word for "a named container of credentials" than
sops/, which only referenced the encryption mechanism.
encrypt-configs -> encrypt-vaults, matching the deploy-targets job it
sits next to (both <verb>-<directory> now). deploy.yml's targets job
becomes deploy-targets to match release.yml's job of the same purpose,
and its step id follows the matrix convention used everywhere else.
Core apps and third-party extra apps were structurally identical
(an apps/ directory) but packaged asymmetrically: core was baked into
flightdeck.zip, extras went through a separate build-bundle+merge path.
Publish flightdeck's own apps/ as flightdeck-apps.zip via the same
build-bundle action, and merge it through the exact same ansible path
as any other app bundle - removing the special-cased "extract core zip
as the apps base" step entirely.
extra_refs is retired in favor of app_refs, now required and non-empty
throughout (targets/*.yml, deploy-shared.yml's app-refs input, and
ansible's flightdeck_app_refs): a target must explicitly list every
app bundle it wants, including flightdeck's own - there's no more
implicit apps/ riding along with the machinery bundle.
… defaults
build-bundle's paths and bundle-name now default to Flightdeck's own
machinery bundle instead of being required on every call, so the
canonical file list lives in one place (the action) instead of inline
in release.yml.
build-apps-bundle is a new, thin defaults wrapper around build-bundle
(paths: apps, bundle-name: flightdeck-apps.zip) used both for
flightdeck's own apps/ catalog and by any consumer repo publishing its
own app bundle the same way.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves Flightdeck into a self-hosting setup that can publish its own machinery/apps bundles, encrypt env assets from vaults/ manifests, and deploy targets/—including a concrete hawkeye target for running traefik + rybbit for rubykatzen.com.

Changes:

  • Introduces a vaults/ + targets/ configuration model (with hawkeye manifests) and updates docs to match.
  • Adds new composite actions (encrypt-env, load-yaml-matrix, build-apps-bundle) and updates release automation to publish both flightdeck.zip and flightdeck-apps.zip.
  • Updates the Ansible deploy flow to merge required app bundles (flightdeck_app_refs) into the release and expands ~ paths per SSH user.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
vaults/hawkeye.ymlAdds a concrete vault manifest mapping GitHub Secrets/Vars into an encrypted env asset and defining the app list.
targets/hawkeye.ymlAdds a concrete deploy target selecting bundles, env asset ref, SSH destination, and credential names.
keys/hawkeye.pubAdds the age recipient public key used by encrypt-env for hawkeye.
ansible/deploy.ymlRequires flightdeck_app_refs and merges app bundles into releases/<ts>/apps; expands ~ paths per SSH user.
README.mdDocuments the new deploy interface (flightdeck_app_refs) and the vaults/targets model + new actions/workflows.
AGENTS.mdUpdates internal architecture docs to reflect the new bundle/env/target workflow model.
.github/workflows/release.ymlReplaces the Release Please workflow and adds jobs to upload both bundles, encrypt vaults, and deploy targets on release_created.
.github/workflows/release-please.ymlRemoves the old Release Please workflow file.
.github/workflows/deploy.ymlAdds a manual workflow_dispatch redeploy workflow that selects one target or all targets.
.github/workflows/deploy-shared.ymlRedesigns the reusable workflow interface to accept hosts, app-ref, env-ref, and non-empty app-refs.
.github/actions/build-bundle/action.ymlExtends the bundle builder to upload to an existing release and provides defaults for Flightdeck’s machinery bundle.
.github/actions/build-apps-bundle/action.ymlAdds a thin wrapper that publishes an apps/-only bundle (flightdeck-apps.zip).
.github/actions/encrypt-env/action.ymlAdds composite action to render env from vaults/*, encrypt via SOPS age, and upload to an existing release.
.github/actions/encrypt-env/README.mdDocuments usage and the new manifest format (asset, keys, apps, env).
.github/actions/encrypt-env/requirements.txtPins PyYAML for the action implementation.
.github/actions/encrypt-env/scripts/render-env.pyEnforces the new manifest schema, renders apps as APPS=..., and writes outputs for downstream steps.
.github/actions/encrypt-env/tests/test_render_env.pyUpdates/extends unit tests for the new manifest schema and APPS rendering rules.
.github/actions/load-yaml-matrix/action.ymlAdds composite action to load a directory of YAML files into a matrix + count outputs.
.github/actions/load-yaml-matrix/README.mdDocuments load-yaml-matrix behavior and filename/key validation rules.
.github/actions/load-yaml-matrix/requirements.txtPins PyYAML for the action implementation.
.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.pyImplements matrix building with duplicate-key detection and filename validation.
.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.pyAdds unit tests covering discovery, filtering, and validation failures.
.github/actions/publish-sops-env/action.ymlRemoves the superseded publish-sops-env action.
.github/actions/publish-sops-env/README.mdRemoves the superseded publish-sops-env docs.
.github/actions/discover-manifest-matrix/action.ymlRemoves the unused manifest discovery action.
.github/actions/discover-manifest-matrix/README.mdRemoves the unused manifest discovery docs.
.github/actionlint.yamlAdds actionlint ignores for newer job context properties used in deploy-shared.yml.
Suppressed comments (6)

.github/workflows/release.yml:40

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/build-apps-bundle

.github/workflows/release.yml:69

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/encrypt-env

.github/workflows/release.yml:55

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:88

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:97

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

.github/workflows/deploy.yml:27

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: $/.github/actions/build-bundle
count: ${{ steps.matrix.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: $/.github/actions/load-yaml-matrix
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.

2 participants

@ineedjet
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: deploy rybbit for rubykatzen.com through flightdeck itself by ineedjet · Pull Request #102 · rubykatzen/flightdeck · GitHub
Skip to content

feat: deploy rybbit for rubykatzen.com through flightdeck itself - #102

Merged
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit
Aug 20, 2026
Merged

feat: deploy rybbit for rubykatzen.com through flightdeck itself#102
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit

Conversation

@ineedjet

@ineedjetineedjet commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Flightdeck now uses its own release and deployment tooling to manage Rybbit for rubykatzen.com on the existing hawkeye server.

The final configuration model separates two independent concerns:

  • vaults/ describes encrypted env assets.
  • targets/ describes deployments and explicitly selects a machinery bundle, app bundles, an env asset, and SSH destinations.

The matching hawkeye.yml filenames are a convention only. The actual relationship is the explicit env_ref in the deployment target, so either collection can live in a repository without the other.

Flightdeck's own apps/ catalog is published as its own release asset (flightdeck-apps.zip), merged into a deploy the same way as any other app bundle. There's no special-cased "core" bundle that apps ride along with implicitly — a target must list every app bundle it wants, flightdeck's own included.

Configuration

vaults/hawkeye.yml defines:

  • the hawkeye.sops.env release asset;
  • the hawkeye age recipient;
  • the plain app list (traefik, rybbit), rendered as APPS=traefik,rybbit;
  • mappings from dotenv names to GitHub Secrets or Variables.

targets/hawkeye.yml defines:

  • machinery bundle: rubykatzen/flightdeck@latest;
  • app bundles (app_refs, required, non-empty): rubykatzen/flightdeck@latest;
  • env asset: rubykatzen/flightdeck@latest:hawkeye.sops.env;
  • SSH destination: rubykatzen-com@100.75.50.2;
  • names of the GitHub credentials needed for SSH and optional Tailscale access.

Credential values are never stored in target files.

Automation

  • Added build-bundle, which builds and uploads a bundle to an existing release; paths/bundle-name default to Flightdeck's own machinery bundle (flightdeck.zip) but are fully overridable.
  • Added build-apps-bundle, a thin defaults wrapper around build-bundle (paths: apps, bundle-name: flightdeck-apps.zip) used both for flightdeck's own apps/ catalog and any consumer repo's own app bundle.
  • Replaced publish-sops-env with encrypt-env, which renders a flat config from a vaults/ manifest, encrypts it with SOPS age recipients, and uploads it to an existing release.
  • Added load-yaml-matrix, a schema-agnostic action that reads every YAML file in a caller-given directory into a GitHub Actions matrix (no directory default, no validation — callers own their manifest shape).
  • Removed the unused manifest discovery action and the asset-oriented reusable workflows; release jobs call the composite actions directly.
  • Renamed the Release Please workflow to release.yml. A created release uploads flightdeck.zip and flightdeck-apps.zip, processes every config in vaults/, and deploys every config in targets/ — all gated on release_created, so an ordinary merge to main that doesn't cut a release does nothing.
  • Added deploy.yml for manual redeploys only (workflow_dispatch, select a target or "all"). It is deliberately not triggered automatically — an earlier version fired on every completed Release workflow run regardless of whether a release was actually created, which would have redeployed hawkeye (and bounced traefik/rybbit) on every ordinary merge to main.

Deployment Interface

deploy-shared.yml exposes deployment vocabulary rather than Ansible implementation details:

  • hosts are JSON arrays of user@host SSH destinations;
  • the machinery bundle, env asset, and app bundles use complete owner/repo@tag[:asset] refs; app-refs is a required, non-empty JSON array;
  • path, retention, and the SOPS age key path have defaults and remain overridable;
  • user@host values are converted to an internal Ansible JSON inventory;
  • ~ paths are expanded per SSH user on each target host.

The default deployment path is ~/flightdeck. The default age key path is ~/.config/sops/age/keys.txt, matching the normal Linux SOPS location for the connecting user while still working under Ansible privilege escalation.

External Prerequisites

  • GitHub Secret DEPLOY_SSH_PRIVATE_KEY.
  • GitHub Variable TAILSCALE_OAUTH_CLIENT_ID and Secret TAILSCALE_OAUTH_SECRET when Tailscale is used.
  • All GitHub Secret/Variable names referenced by vaults/hawkeye.yml.
  • The server-side rubykatzen-com user, corresponding SSH access and age private key, Docker, GitHub CLI, SOPS, and the external Cloudflare Tunnel configuration.

Verification

  • 9 load-yaml-matrix unit tests
  • 8 encrypt-env unit tests
  • Actual encrypt and deploy matrix generation, traced against the real vaults/hawkeye.yml and targets/hawkeye.yml
  • Generated inventory parsed by ansible-inventory
  • ansible-playbook --syntax-check ansible/deploy.yml
  • pre-commit run --all-files
  • GitHub Embedder, GitHub config, and lint checks
  • Publish the first real Hawkeye env asset after external prerequisites are configured
  • Run the first real Hawkeye deployment

ineedjetand others added 30 commits August 18, 2026 21:27
First real (non-CI-test) self-consumption of deploy-shared.yml:
flightdeck deploys its own rybbit instance at rybbit.rubykatzen.com,
via an isolated rubykatzen-com user on the existing hawkeye host
(Tailscale IP 100.75.50.2), separate from dupmachine's own services
on that box (own traefik on 8080/8443 to avoid the port conflict,
own age keypair, own env manifest).
- projects/rubykatzen/rubykatzen-com.yml: publish-sops-env manifest
(APPS=traefik,rybbit, Cloudflare DNS challenge cert resolver).
- keys/rubykatzen-com.pub: age recipient for this deployment's
encrypted env. Private key generated locally, not committed --
goes on the server at flightdeck_sops_age_key_file, out of band.
- publish-rubykatzen-com-env.yml: renders + encrypts the manifest,
publishes it to the rubykatzen-com-config release tag.
- deploy-rubykatzen-com.yml: calls deploy-shared.yml@v0.3.0 (pinned,
same as a real external consumer would) against 100.75.50.2.
Blocked on, not part of this diff: DEPLOY_SSH_PRIVATE_KEY secret,
TAILSCALE_OAUTH_CLIENT_ID/SECRET, RUBYKATZEN_COM_CLOUDFLARE_TOKEN
secret, and server-side prep (create rubykatzen-com user, install the
age private key, cloudflared tunnel). workflow_dispatch-only until
those land.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wkeye.yml
The manifest identity is the server + isolated user + age key it
decrypts on (hawkeye), not the domain/service currently running
there (rubykatzen.com/rybbit) or a repo-grouping level that doesn't
apply to self-hosting flightdeck's own deploys. Matches the existing
convention elsewhere of naming manifests/assets after the server
(mainframe, hawkeye), and drops the redundant projects/rubykatzen/
nesting -- one flat targets/ directory, one file per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the targets/ rename: the age key, release tag, and
asset identify the deploy target (server + isolated user + key), not
the service currently running there. Renamed rubykatzen-com.pub ->
hawkeye.pub, rubykatzen-com-config -> hawkeye-config,
rubykatzen-com.sops.env -> hawkeye.sops.env. The RUBYKATZEN_COM_*
GitHub variable/secret names are untouched -- those describe the
service's own config content, not the target identity, and the
rubykatzen-com OS username/home path on the server is a separate,
correctly-still-rubykatzen-com concept.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
We were the odd one out: deploy-rubykatzen-com.yml already calls
deploy-shared.yml as a real interface, but publish-rubykatzen-com-env.yml
hand-assembled its own "ensure release exists" step around the local
publish-sops-env composite action instead of going through an
equivalent reusable workflow -- because that reusable workflow didn't
exist yet, only the composite action did.
Add publish-sops-env-shared.yml (checkout, resolve release tag --
input or read from the manifest's own release_tag, ensure the release
exists, call publish-sops-env) and rewrite our own
publish-rubykatzen-com-env.yml to just call it, matching the same
consumer interface pattern deploy-rubykatzen-com.yml already uses.
Note: this one needs `secrets: inherit` on the caller, unlike
deploy-shared.yml's explicit named secrets -- the manifest can
reference an arbitrary, per-target set of secret/variable names, so
there's no fixed schema to declare individually.
Pinned to @main for now since this file doesn't exist in any released
tag yet; re-pin to a real version once the next release ships, same
as deploy-rubykatzen-com.yml does for deploy-shared.yml@v0.3.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an interface
The old interface leaked Ansible directly to callers -- extra-vars
was a hand-written JSON blob using flightdeck_* variable names lifted
straight from ansible/deploy.yml. Callers shouldn't need to know this
runs on Ansible at all.
Replace with named inputs in plain deploy vocabulary: app-ref,
env-ref, extra-refs (comma-separated), path, keep-releases,
sops-age-key-file. The workflow builds the ansible -e JSON internally
via jq. No behavior change for existing values, just the surface.
Added defaults for the ones that have a sensible one:
- path: ~/flightdeck (matches every example/consumer so far)
- keep-releases: 5
- sops-age-key-file: ~/.config/sops/age/keys.txt for the connecting
`user`, computed rather than hardcoded since it depends on which
user connects -- can't be a static workflow_call default.
We have no external consumers of this interface yet to break, so
doing this now rather than carrying the leaky version forward.
Also updates our own deploy-rubykatzen-com.yml to the new interface
(drops explicit path/keep-releases/sops-age-key-file entirely since
the new defaults already match what we were passing), and README's
deploy-shared.yml documentation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the earlier targets/keys/release-tag rename: these
workflows deploy a whole target's APPS list, not one specific
service. If hawkeye ever hosts a second app alongside rybbit, it's
the same workflow, same target -- naming it after rubykatzen.com
(the service currently there) would become misleading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One deploy entry point for the whole repo instead of one workflow
file per target. Each target is its own job (currently just
hawkeye); a workflow_dispatch `target` input picks which job(s) run
-- "all" (default) runs every target, a specific name runs just that
job. This is also the pattern we want client repos to copy: a system
that knows exactly which target it just pushed config for can
`gh workflow run deploy.yml -f target=<name>` instead of redeploying
the whole fleet.
No scheduling added yet -- that depends on the idempotent
reconciliation work in #100/#101 landing first, otherwise a cron
trigger would just force-restart everything on every tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fter it
- Rename release-please.yml -> release.yml (name: Release Please ->
Release), matching the file.
- deploy.yml now also fires on workflow_run of Release (in addition
to manual workflow_dispatch). Auto-redeploys hawkeye whenever
Release completes successfully -- including no-op runs where
release-please found nothing to release, since workflow_run
doesn't expose that workflow's own job outputs. Accepted as an MVP
tradeoff: harmless once deploy.sh is idempotent (#100), just an
unnecessary restart until then.
- Fixed a real bug this surfaced: deploy-shared.yml's app-ref input
was reused as both the checkout ref (needs a real git ref: branch,
tag, SHA) and the flightdeck_app_ref bundle to deploy (can be
'latest', a GitHub Releases API concept, not a git ref). Passing
app-ref: latest previously would have broken the checkout step.
Split "Resolve checkout ref" (always derived from workflow_ref,
used only for actions/checkout) from app-ref resolution inside
"Build extra-vars" (input override, defaulting to the checkout ref
if not given). deploy.yml's hawkeye job now passes app-ref: latest
so it always deploys the newest release regardless of which ref
runs the playbook mechanism itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No external consumers yet, so no reason to carry the extra branch:
"if app-ref is empty, reuse whatever ref runs the playbook mechanism"
was solving a DRY concern (avoid writing the version twice) at the
cost of conflating two genuinely different things -- which ref checks
out ansible/deploy.yml vs which release gets deployed. Now that we
need them to diverge (app-ref: latest while pinned to a stable
mechanism version), the implicit default is just a trap. Require
app-ref explicitly instead; deploy.yml already passes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml's upload job was hand-assembled from checkout +
build-bundle + gh release upload steps directly, the same pattern we
already moved away from for publish-sops-env. Extract it into a
generic reusable workflow: checkout a ref, build a zip via the
build-bundle composite action, upload it to an existing release.
release.yml's own upload job now just calls it via a local path
(./.github/workflows/upload-bundle-shared.yml) -- same-repo
self-reference, no ref-pinning concern since it always runs from
whatever commit release.yml itself runs from.
Also generic enough for external use: a consumer repo packaging its
own apps/ directory as an extra bundle for flightdeck_extra_refs can
call it the same way (checkout defaults to the caller's own repo,
same as any reusable workflow without an explicit repository:
override).
Docs: added README sections for both upload-bundle-shared.yml and
publish-sops-env-shared.yml (the latter existed but was never
documented), updated the GitHub Actions intro sentence to list all
three reusable workflows, and fixed a stale release-please.yml
filename left over in the project tree from the earlier rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f upload-apps-shared.yml
upload-bundle-shared.yml took bundle-name/paths as caller inputs, but
in practice there's exactly one caller (this repo's own release.yml)
with fixed, well-known content -- flightdeck.zip is core scripts +
apps/ + README, not something that varies per call. Generic
parameterization here just pushed config a consumer shouldn't need to
know onto the call site. Baked it in; the workflow now only takes
ref/release-tag.
Split the actual generic use case -- a consumer repo bundling its own
apps/ as an extra bundle for flightdeck_extra_refs -- into its own
purpose-built upload-apps-shared.yml (fixed to apps.zip / apps/ only).
Added a comment at release.yml's upload job pointing consumers at it,
so reading our own release flow makes the intended split obvious
rather than inviting a copy-paste of flightdeck-specific config.
Also: switched every local composite-action reference inside these
shared workflows (publish-sops-env-shared.yml, upload-bundle-shared.yml,
upload-apps-shared.yml) from ./.github/actions/x to $/.github/actions/x
-- GitHub's new (July 2026) self-repository syntax. ./ inside a
reusable workflow resolves against the CALLER's repo, which is wrong
here and would have broken the moment an external repo actually called
publish-sops-env-shared.yml or upload-apps-shared.yml (our own usage
so far has been same-repo, so this was latent). $/ always resolves
against the workflow's own defining repo at the exact ref running,
regardless of caller -- matches the pattern rubykatzen/baseline
already uses in lint-shared.yml.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… a separate trigger
publish-hawkeye-env.yml had its own independent trigger (push when
targets/hawkeye.yml or keys/hawkeye.pub changed), decoupled from
whether a release actually happened. Config changes for a target are
conceptually part of the release cycle here, same as the bundle --
fold it into release.yml as another job gated on release_created,
same shape as upload. Delete the standalone workflow; a per-target
manual/path-based publish trigger isn't needed once every target's
env publish just rides along with the release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the publish-hawkeye-env.yml removal in
2b7b16a -- this is the actual job that replaces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Predates the explicit per-target job pattern (targets/hawkeye.yml,
named jobs in release.yml/deploy.yml) this repo now actually uses --
it was built for a dynamic glob-based matrix discovery approach we
never ended up using here, and nothing in this repo calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the action removal in d1472bb.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build-bundle was the odd one out -- publish-sops-env already does
render+encrypt+upload as one action, but build-bundle only built the
zip and left the gh release upload as a separate step in each caller.
Fold upload into build-bundle itself (new release-tag/token inputs),
so upload-bundle-shared.yml and upload-apps-shared.yml both drop to
checkout + one action call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deploy.yml previously fired on every completed "Release" workflow_run,
including runs where release-please created no release - which meant
every ordinary merge to main would redeploy hawkeye and bounce
traefik/rybbit. Move the automatic deploy into release.yml, gated on
release_created like upload/targets/encrypt, and leave deploy.yml with
only the manual workflow_dispatch redeploy path.
"targets" already means deploy destinations (servers). Reusing that
name for the generic config-matrix reader used by both encrypt and
deploy modes was confusing. Rename the action to load-matrix, its
name-selector input/flag from target to name, and the matrix item's
target field to name. Also rename release.yml's encrypt-mode job from
targets to encrypt-configs so it doesn't collide semantically with the
new deploy-targets job.
Rename load-matrix to load-yaml-matrix and strip out its encrypt/deploy
schema knowledge. It now just reads every YAML file in a directory,
merges each file's top-level fields with name/manifest, and builds a
matrix - no mode, no field validation, no defaults.
encrypt-env already re-parses and validates its own manifest from the
file path, so the encrypt/ path loses nothing. The targets/ path had
no other validator, so move what it relied on into the callers:
release.yml and deploy.yml now apply the path/keep-releases/sops-age-
key-file defaults inline and read credentials.secrets/credentials.variables
directly off the raw matrix item instead of the flattened fields
load-matrix used to produce.
This drops schema validation entirely (SSH destination format, ref
format, duplicate hosts, unknown-key typos) - a malformed targets/*.yml
now only surfaces once deploy-shared.yml or ansible/deploy.yml actually
runs against production hosts, not before.
targets/ is a plural noun naming what it holds (deploy targets).
encrypt/ was a singular verb, inconsistent with that pattern and with
its own contents (env asset configs, one per encrypt-env manifest).
Rename to envs/ to match.
envs/ read unnaturally next to targets/ and keys/. sops/ matches the
asset naming convention already used throughout (*.sops.env) and pairs
directly with keys/, which already holds the age recipients used to
encrypt into that directory's assets.
One repo prepares vaults from a pool of secrets/vars; different
consumers (targets) unpack the vault meant for them. Also just a
better English word for "a named container of credentials" than
sops/, which only referenced the encryption mechanism.
encrypt-configs -> encrypt-vaults, matching the deploy-targets job it
sits next to (both <verb>-<directory> now). deploy.yml's targets job
becomes deploy-targets to match release.yml's job of the same purpose,
and its step id follows the matrix convention used everywhere else.
Core apps and third-party extra apps were structurally identical
(an apps/ directory) but packaged asymmetrically: core was baked into
flightdeck.zip, extras went through a separate build-bundle+merge path.
Publish flightdeck's own apps/ as flightdeck-apps.zip via the same
build-bundle action, and merge it through the exact same ansible path
as any other app bundle - removing the special-cased "extract core zip
as the apps base" step entirely.
extra_refs is retired in favor of app_refs, now required and non-empty
throughout (targets/*.yml, deploy-shared.yml's app-refs input, and
ansible's flightdeck_app_refs): a target must explicitly list every
app bundle it wants, including flightdeck's own - there's no more
implicit apps/ riding along with the machinery bundle.
… defaults
build-bundle's paths and bundle-name now default to Flightdeck's own
machinery bundle instead of being required on every call, so the
canonical file list lives in one place (the action) instead of inline
in release.yml.
build-apps-bundle is a new, thin defaults wrapper around build-bundle
(paths: apps, bundle-name: flightdeck-apps.zip) used both for
flightdeck's own apps/ catalog and by any consumer repo publishing its
own app bundle the same way.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves Flightdeck into a self-hosting setup that can publish its own machinery/apps bundles, encrypt env assets from vaults/ manifests, and deploy targets/—including a concrete hawkeye target for running traefik + rybbit for rubykatzen.com.

Changes:

  • Introduces a vaults/ + targets/ configuration model (with hawkeye manifests) and updates docs to match.
  • Adds new composite actions (encrypt-env, load-yaml-matrix, build-apps-bundle) and updates release automation to publish both flightdeck.zip and flightdeck-apps.zip.
  • Updates the Ansible deploy flow to merge required app bundles (flightdeck_app_refs) into the release and expands ~ paths per SSH user.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
vaults/hawkeye.ymlAdds a concrete vault manifest mapping GitHub Secrets/Vars into an encrypted env asset and defining the app list.
targets/hawkeye.ymlAdds a concrete deploy target selecting bundles, env asset ref, SSH destination, and credential names.
keys/hawkeye.pubAdds the age recipient public key used by encrypt-env for hawkeye.
ansible/deploy.ymlRequires flightdeck_app_refs and merges app bundles into releases/<ts>/apps; expands ~ paths per SSH user.
README.mdDocuments the new deploy interface (flightdeck_app_refs) and the vaults/targets model + new actions/workflows.
AGENTS.mdUpdates internal architecture docs to reflect the new bundle/env/target workflow model.
.github/workflows/release.ymlReplaces the Release Please workflow and adds jobs to upload both bundles, encrypt vaults, and deploy targets on release_created.
.github/workflows/release-please.ymlRemoves the old Release Please workflow file.
.github/workflows/deploy.ymlAdds a manual workflow_dispatch redeploy workflow that selects one target or all targets.
.github/workflows/deploy-shared.ymlRedesigns the reusable workflow interface to accept hosts, app-ref, env-ref, and non-empty app-refs.
.github/actions/build-bundle/action.ymlExtends the bundle builder to upload to an existing release and provides defaults for Flightdeck’s machinery bundle.
.github/actions/build-apps-bundle/action.ymlAdds a thin wrapper that publishes an apps/-only bundle (flightdeck-apps.zip).
.github/actions/encrypt-env/action.ymlAdds composite action to render env from vaults/*, encrypt via SOPS age, and upload to an existing release.
.github/actions/encrypt-env/README.mdDocuments usage and the new manifest format (asset, keys, apps, env).
.github/actions/encrypt-env/requirements.txtPins PyYAML for the action implementation.
.github/actions/encrypt-env/scripts/render-env.pyEnforces the new manifest schema, renders apps as APPS=..., and writes outputs for downstream steps.
.github/actions/encrypt-env/tests/test_render_env.pyUpdates/extends unit tests for the new manifest schema and APPS rendering rules.
.github/actions/load-yaml-matrix/action.ymlAdds composite action to load a directory of YAML files into a matrix + count outputs.
.github/actions/load-yaml-matrix/README.mdDocuments load-yaml-matrix behavior and filename/key validation rules.
.github/actions/load-yaml-matrix/requirements.txtPins PyYAML for the action implementation.
.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.pyImplements matrix building with duplicate-key detection and filename validation.
.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.pyAdds unit tests covering discovery, filtering, and validation failures.
.github/actions/publish-sops-env/action.ymlRemoves the superseded publish-sops-env action.
.github/actions/publish-sops-env/README.mdRemoves the superseded publish-sops-env docs.
.github/actions/discover-manifest-matrix/action.ymlRemoves the unused manifest discovery action.
.github/actions/discover-manifest-matrix/README.mdRemoves the unused manifest discovery docs.
.github/actionlint.yamlAdds actionlint ignores for newer job context properties used in deploy-shared.yml.
Suppressed comments (6)

.github/workflows/release.yml:40

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/build-apps-bundle

.github/workflows/release.yml:69

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/encrypt-env

.github/workflows/release.yml:55

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:88

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:97

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

.github/workflows/deploy.yml:27

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: $/.github/actions/build-bundle
count: ${{ steps.matrix.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: $/.github/actions/load-yaml-matrix
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.

2 participants

@ineedjet
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: deploy rybbit for rubykatzen.com through flightdeck itself by ineedjet · Pull Request #102 · rubykatzen/flightdeck · GitHub
Skip to content

feat: deploy rybbit for rubykatzen.com through flightdeck itself - #102

Merged
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit
Aug 20, 2026
Merged

feat: deploy rybbit for rubykatzen.com through flightdeck itself#102
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit

Conversation

@ineedjet

@ineedjetineedjet commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Flightdeck now uses its own release and deployment tooling to manage Rybbit for rubykatzen.com on the existing hawkeye server.

The final configuration model separates two independent concerns:

  • vaults/ describes encrypted env assets.
  • targets/ describes deployments and explicitly selects a machinery bundle, app bundles, an env asset, and SSH destinations.

The matching hawkeye.yml filenames are a convention only. The actual relationship is the explicit env_ref in the deployment target, so either collection can live in a repository without the other.

Flightdeck's own apps/ catalog is published as its own release asset (flightdeck-apps.zip), merged into a deploy the same way as any other app bundle. There's no special-cased "core" bundle that apps ride along with implicitly — a target must list every app bundle it wants, flightdeck's own included.

Configuration

vaults/hawkeye.yml defines:

  • the hawkeye.sops.env release asset;
  • the hawkeye age recipient;
  • the plain app list (traefik, rybbit), rendered as APPS=traefik,rybbit;
  • mappings from dotenv names to GitHub Secrets or Variables.

targets/hawkeye.yml defines:

  • machinery bundle: rubykatzen/flightdeck@latest;
  • app bundles (app_refs, required, non-empty): rubykatzen/flightdeck@latest;
  • env asset: rubykatzen/flightdeck@latest:hawkeye.sops.env;
  • SSH destination: rubykatzen-com@100.75.50.2;
  • names of the GitHub credentials needed for SSH and optional Tailscale access.

Credential values are never stored in target files.

Automation

  • Added build-bundle, which builds and uploads a bundle to an existing release; paths/bundle-name default to Flightdeck's own machinery bundle (flightdeck.zip) but are fully overridable.
  • Added build-apps-bundle, a thin defaults wrapper around build-bundle (paths: apps, bundle-name: flightdeck-apps.zip) used both for flightdeck's own apps/ catalog and any consumer repo's own app bundle.
  • Replaced publish-sops-env with encrypt-env, which renders a flat config from a vaults/ manifest, encrypts it with SOPS age recipients, and uploads it to an existing release.
  • Added load-yaml-matrix, a schema-agnostic action that reads every YAML file in a caller-given directory into a GitHub Actions matrix (no directory default, no validation — callers own their manifest shape).
  • Removed the unused manifest discovery action and the asset-oriented reusable workflows; release jobs call the composite actions directly.
  • Renamed the Release Please workflow to release.yml. A created release uploads flightdeck.zip and flightdeck-apps.zip, processes every config in vaults/, and deploys every config in targets/ — all gated on release_created, so an ordinary merge to main that doesn't cut a release does nothing.
  • Added deploy.yml for manual redeploys only (workflow_dispatch, select a target or "all"). It is deliberately not triggered automatically — an earlier version fired on every completed Release workflow run regardless of whether a release was actually created, which would have redeployed hawkeye (and bounced traefik/rybbit) on every ordinary merge to main.

Deployment Interface

deploy-shared.yml exposes deployment vocabulary rather than Ansible implementation details:

  • hosts are JSON arrays of user@host SSH destinations;
  • the machinery bundle, env asset, and app bundles use complete owner/repo@tag[:asset] refs; app-refs is a required, non-empty JSON array;
  • path, retention, and the SOPS age key path have defaults and remain overridable;
  • user@host values are converted to an internal Ansible JSON inventory;
  • ~ paths are expanded per SSH user on each target host.

The default deployment path is ~/flightdeck. The default age key path is ~/.config/sops/age/keys.txt, matching the normal Linux SOPS location for the connecting user while still working under Ansible privilege escalation.

External Prerequisites

  • GitHub Secret DEPLOY_SSH_PRIVATE_KEY.
  • GitHub Variable TAILSCALE_OAUTH_CLIENT_ID and Secret TAILSCALE_OAUTH_SECRET when Tailscale is used.
  • All GitHub Secret/Variable names referenced by vaults/hawkeye.yml.
  • The server-side rubykatzen-com user, corresponding SSH access and age private key, Docker, GitHub CLI, SOPS, and the external Cloudflare Tunnel configuration.

Verification

  • 9 load-yaml-matrix unit tests
  • 8 encrypt-env unit tests
  • Actual encrypt and deploy matrix generation, traced against the real vaults/hawkeye.yml and targets/hawkeye.yml
  • Generated inventory parsed by ansible-inventory
  • ansible-playbook --syntax-check ansible/deploy.yml
  • pre-commit run --all-files
  • GitHub Embedder, GitHub config, and lint checks
  • Publish the first real Hawkeye env asset after external prerequisites are configured
  • Run the first real Hawkeye deployment

ineedjetand others added 30 commits August 18, 2026 21:27
First real (non-CI-test) self-consumption of deploy-shared.yml:
flightdeck deploys its own rybbit instance at rybbit.rubykatzen.com,
via an isolated rubykatzen-com user on the existing hawkeye host
(Tailscale IP 100.75.50.2), separate from dupmachine's own services
on that box (own traefik on 8080/8443 to avoid the port conflict,
own age keypair, own env manifest).
- projects/rubykatzen/rubykatzen-com.yml: publish-sops-env manifest
(APPS=traefik,rybbit, Cloudflare DNS challenge cert resolver).
- keys/rubykatzen-com.pub: age recipient for this deployment's
encrypted env. Private key generated locally, not committed --
goes on the server at flightdeck_sops_age_key_file, out of band.
- publish-rubykatzen-com-env.yml: renders + encrypts the manifest,
publishes it to the rubykatzen-com-config release tag.
- deploy-rubykatzen-com.yml: calls deploy-shared.yml@v0.3.0 (pinned,
same as a real external consumer would) against 100.75.50.2.
Blocked on, not part of this diff: DEPLOY_SSH_PRIVATE_KEY secret,
TAILSCALE_OAUTH_CLIENT_ID/SECRET, RUBYKATZEN_COM_CLOUDFLARE_TOKEN
secret, and server-side prep (create rubykatzen-com user, install the
age private key, cloudflared tunnel). workflow_dispatch-only until
those land.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wkeye.yml
The manifest identity is the server + isolated user + age key it
decrypts on (hawkeye), not the domain/service currently running
there (rubykatzen.com/rybbit) or a repo-grouping level that doesn't
apply to self-hosting flightdeck's own deploys. Matches the existing
convention elsewhere of naming manifests/assets after the server
(mainframe, hawkeye), and drops the redundant projects/rubykatzen/
nesting -- one flat targets/ directory, one file per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the targets/ rename: the age key, release tag, and
asset identify the deploy target (server + isolated user + key), not
the service currently running there. Renamed rubykatzen-com.pub ->
hawkeye.pub, rubykatzen-com-config -> hawkeye-config,
rubykatzen-com.sops.env -> hawkeye.sops.env. The RUBYKATZEN_COM_*
GitHub variable/secret names are untouched -- those describe the
service's own config content, not the target identity, and the
rubykatzen-com OS username/home path on the server is a separate,
correctly-still-rubykatzen-com concept.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
We were the odd one out: deploy-rubykatzen-com.yml already calls
deploy-shared.yml as a real interface, but publish-rubykatzen-com-env.yml
hand-assembled its own "ensure release exists" step around the local
publish-sops-env composite action instead of going through an
equivalent reusable workflow -- because that reusable workflow didn't
exist yet, only the composite action did.
Add publish-sops-env-shared.yml (checkout, resolve release tag --
input or read from the manifest's own release_tag, ensure the release
exists, call publish-sops-env) and rewrite our own
publish-rubykatzen-com-env.yml to just call it, matching the same
consumer interface pattern deploy-rubykatzen-com.yml already uses.
Note: this one needs `secrets: inherit` on the caller, unlike
deploy-shared.yml's explicit named secrets -- the manifest can
reference an arbitrary, per-target set of secret/variable names, so
there's no fixed schema to declare individually.
Pinned to @main for now since this file doesn't exist in any released
tag yet; re-pin to a real version once the next release ships, same
as deploy-rubykatzen-com.yml does for deploy-shared.yml@v0.3.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an interface
The old interface leaked Ansible directly to callers -- extra-vars
was a hand-written JSON blob using flightdeck_* variable names lifted
straight from ansible/deploy.yml. Callers shouldn't need to know this
runs on Ansible at all.
Replace with named inputs in plain deploy vocabulary: app-ref,
env-ref, extra-refs (comma-separated), path, keep-releases,
sops-age-key-file. The workflow builds the ansible -e JSON internally
via jq. No behavior change for existing values, just the surface.
Added defaults for the ones that have a sensible one:
- path: ~/flightdeck (matches every example/consumer so far)
- keep-releases: 5
- sops-age-key-file: ~/.config/sops/age/keys.txt for the connecting
`user`, computed rather than hardcoded since it depends on which
user connects -- can't be a static workflow_call default.
We have no external consumers of this interface yet to break, so
doing this now rather than carrying the leaky version forward.
Also updates our own deploy-rubykatzen-com.yml to the new interface
(drops explicit path/keep-releases/sops-age-key-file entirely since
the new defaults already match what we were passing), and README's
deploy-shared.yml documentation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the earlier targets/keys/release-tag rename: these
workflows deploy a whole target's APPS list, not one specific
service. If hawkeye ever hosts a second app alongside rybbit, it's
the same workflow, same target -- naming it after rubykatzen.com
(the service currently there) would become misleading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One deploy entry point for the whole repo instead of one workflow
file per target. Each target is its own job (currently just
hawkeye); a workflow_dispatch `target` input picks which job(s) run
-- "all" (default) runs every target, a specific name runs just that
job. This is also the pattern we want client repos to copy: a system
that knows exactly which target it just pushed config for can
`gh workflow run deploy.yml -f target=<name>` instead of redeploying
the whole fleet.
No scheduling added yet -- that depends on the idempotent
reconciliation work in #100/#101 landing first, otherwise a cron
trigger would just force-restart everything on every tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fter it
- Rename release-please.yml -> release.yml (name: Release Please ->
Release), matching the file.
- deploy.yml now also fires on workflow_run of Release (in addition
to manual workflow_dispatch). Auto-redeploys hawkeye whenever
Release completes successfully -- including no-op runs where
release-please found nothing to release, since workflow_run
doesn't expose that workflow's own job outputs. Accepted as an MVP
tradeoff: harmless once deploy.sh is idempotent (#100), just an
unnecessary restart until then.
- Fixed a real bug this surfaced: deploy-shared.yml's app-ref input
was reused as both the checkout ref (needs a real git ref: branch,
tag, SHA) and the flightdeck_app_ref bundle to deploy (can be
'latest', a GitHub Releases API concept, not a git ref). Passing
app-ref: latest previously would have broken the checkout step.
Split "Resolve checkout ref" (always derived from workflow_ref,
used only for actions/checkout) from app-ref resolution inside
"Build extra-vars" (input override, defaulting to the checkout ref
if not given). deploy.yml's hawkeye job now passes app-ref: latest
so it always deploys the newest release regardless of which ref
runs the playbook mechanism itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No external consumers yet, so no reason to carry the extra branch:
"if app-ref is empty, reuse whatever ref runs the playbook mechanism"
was solving a DRY concern (avoid writing the version twice) at the
cost of conflating two genuinely different things -- which ref checks
out ansible/deploy.yml vs which release gets deployed. Now that we
need them to diverge (app-ref: latest while pinned to a stable
mechanism version), the implicit default is just a trap. Require
app-ref explicitly instead; deploy.yml already passes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml's upload job was hand-assembled from checkout +
build-bundle + gh release upload steps directly, the same pattern we
already moved away from for publish-sops-env. Extract it into a
generic reusable workflow: checkout a ref, build a zip via the
build-bundle composite action, upload it to an existing release.
release.yml's own upload job now just calls it via a local path
(./.github/workflows/upload-bundle-shared.yml) -- same-repo
self-reference, no ref-pinning concern since it always runs from
whatever commit release.yml itself runs from.
Also generic enough for external use: a consumer repo packaging its
own apps/ directory as an extra bundle for flightdeck_extra_refs can
call it the same way (checkout defaults to the caller's own repo,
same as any reusable workflow without an explicit repository:
override).
Docs: added README sections for both upload-bundle-shared.yml and
publish-sops-env-shared.yml (the latter existed but was never
documented), updated the GitHub Actions intro sentence to list all
three reusable workflows, and fixed a stale release-please.yml
filename left over in the project tree from the earlier rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f upload-apps-shared.yml
upload-bundle-shared.yml took bundle-name/paths as caller inputs, but
in practice there's exactly one caller (this repo's own release.yml)
with fixed, well-known content -- flightdeck.zip is core scripts +
apps/ + README, not something that varies per call. Generic
parameterization here just pushed config a consumer shouldn't need to
know onto the call site. Baked it in; the workflow now only takes
ref/release-tag.
Split the actual generic use case -- a consumer repo bundling its own
apps/ as an extra bundle for flightdeck_extra_refs -- into its own
purpose-built upload-apps-shared.yml (fixed to apps.zip / apps/ only).
Added a comment at release.yml's upload job pointing consumers at it,
so reading our own release flow makes the intended split obvious
rather than inviting a copy-paste of flightdeck-specific config.
Also: switched every local composite-action reference inside these
shared workflows (publish-sops-env-shared.yml, upload-bundle-shared.yml,
upload-apps-shared.yml) from ./.github/actions/x to $/.github/actions/x
-- GitHub's new (July 2026) self-repository syntax. ./ inside a
reusable workflow resolves against the CALLER's repo, which is wrong
here and would have broken the moment an external repo actually called
publish-sops-env-shared.yml or upload-apps-shared.yml (our own usage
so far has been same-repo, so this was latent). $/ always resolves
against the workflow's own defining repo at the exact ref running,
regardless of caller -- matches the pattern rubykatzen/baseline
already uses in lint-shared.yml.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… a separate trigger
publish-hawkeye-env.yml had its own independent trigger (push when
targets/hawkeye.yml or keys/hawkeye.pub changed), decoupled from
whether a release actually happened. Config changes for a target are
conceptually part of the release cycle here, same as the bundle --
fold it into release.yml as another job gated on release_created,
same shape as upload. Delete the standalone workflow; a per-target
manual/path-based publish trigger isn't needed once every target's
env publish just rides along with the release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the publish-hawkeye-env.yml removal in
2b7b16a -- this is the actual job that replaces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Predates the explicit per-target job pattern (targets/hawkeye.yml,
named jobs in release.yml/deploy.yml) this repo now actually uses --
it was built for a dynamic glob-based matrix discovery approach we
never ended up using here, and nothing in this repo calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the action removal in d1472bb.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build-bundle was the odd one out -- publish-sops-env already does
render+encrypt+upload as one action, but build-bundle only built the
zip and left the gh release upload as a separate step in each caller.
Fold upload into build-bundle itself (new release-tag/token inputs),
so upload-bundle-shared.yml and upload-apps-shared.yml both drop to
checkout + one action call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deploy.yml previously fired on every completed "Release" workflow_run,
including runs where release-please created no release - which meant
every ordinary merge to main would redeploy hawkeye and bounce
traefik/rybbit. Move the automatic deploy into release.yml, gated on
release_created like upload/targets/encrypt, and leave deploy.yml with
only the manual workflow_dispatch redeploy path.
"targets" already means deploy destinations (servers). Reusing that
name for the generic config-matrix reader used by both encrypt and
deploy modes was confusing. Rename the action to load-matrix, its
name-selector input/flag from target to name, and the matrix item's
target field to name. Also rename release.yml's encrypt-mode job from
targets to encrypt-configs so it doesn't collide semantically with the
new deploy-targets job.
Rename load-matrix to load-yaml-matrix and strip out its encrypt/deploy
schema knowledge. It now just reads every YAML file in a directory,
merges each file's top-level fields with name/manifest, and builds a
matrix - no mode, no field validation, no defaults.
encrypt-env already re-parses and validates its own manifest from the
file path, so the encrypt/ path loses nothing. The targets/ path had
no other validator, so move what it relied on into the callers:
release.yml and deploy.yml now apply the path/keep-releases/sops-age-
key-file defaults inline and read credentials.secrets/credentials.variables
directly off the raw matrix item instead of the flattened fields
load-matrix used to produce.
This drops schema validation entirely (SSH destination format, ref
format, duplicate hosts, unknown-key typos) - a malformed targets/*.yml
now only surfaces once deploy-shared.yml or ansible/deploy.yml actually
runs against production hosts, not before.
targets/ is a plural noun naming what it holds (deploy targets).
encrypt/ was a singular verb, inconsistent with that pattern and with
its own contents (env asset configs, one per encrypt-env manifest).
Rename to envs/ to match.
envs/ read unnaturally next to targets/ and keys/. sops/ matches the
asset naming convention already used throughout (*.sops.env) and pairs
directly with keys/, which already holds the age recipients used to
encrypt into that directory's assets.
One repo prepares vaults from a pool of secrets/vars; different
consumers (targets) unpack the vault meant for them. Also just a
better English word for "a named container of credentials" than
sops/, which only referenced the encryption mechanism.
encrypt-configs -> encrypt-vaults, matching the deploy-targets job it
sits next to (both <verb>-<directory> now). deploy.yml's targets job
becomes deploy-targets to match release.yml's job of the same purpose,
and its step id follows the matrix convention used everywhere else.
Core apps and third-party extra apps were structurally identical
(an apps/ directory) but packaged asymmetrically: core was baked into
flightdeck.zip, extras went through a separate build-bundle+merge path.
Publish flightdeck's own apps/ as flightdeck-apps.zip via the same
build-bundle action, and merge it through the exact same ansible path
as any other app bundle - removing the special-cased "extract core zip
as the apps base" step entirely.
extra_refs is retired in favor of app_refs, now required and non-empty
throughout (targets/*.yml, deploy-shared.yml's app-refs input, and
ansible's flightdeck_app_refs): a target must explicitly list every
app bundle it wants, including flightdeck's own - there's no more
implicit apps/ riding along with the machinery bundle.
… defaults
build-bundle's paths and bundle-name now default to Flightdeck's own
machinery bundle instead of being required on every call, so the
canonical file list lives in one place (the action) instead of inline
in release.yml.
build-apps-bundle is a new, thin defaults wrapper around build-bundle
(paths: apps, bundle-name: flightdeck-apps.zip) used both for
flightdeck's own apps/ catalog and by any consumer repo publishing its
own app bundle the same way.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves Flightdeck into a self-hosting setup that can publish its own machinery/apps bundles, encrypt env assets from vaults/ manifests, and deploy targets/—including a concrete hawkeye target for running traefik + rybbit for rubykatzen.com.

Changes:

  • Introduces a vaults/ + targets/ configuration model (with hawkeye manifests) and updates docs to match.
  • Adds new composite actions (encrypt-env, load-yaml-matrix, build-apps-bundle) and updates release automation to publish both flightdeck.zip and flightdeck-apps.zip.
  • Updates the Ansible deploy flow to merge required app bundles (flightdeck_app_refs) into the release and expands ~ paths per SSH user.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
vaults/hawkeye.ymlAdds a concrete vault manifest mapping GitHub Secrets/Vars into an encrypted env asset and defining the app list.
targets/hawkeye.ymlAdds a concrete deploy target selecting bundles, env asset ref, SSH destination, and credential names.
keys/hawkeye.pubAdds the age recipient public key used by encrypt-env for hawkeye.
ansible/deploy.ymlRequires flightdeck_app_refs and merges app bundles into releases/<ts>/apps; expands ~ paths per SSH user.
README.mdDocuments the new deploy interface (flightdeck_app_refs) and the vaults/targets model + new actions/workflows.
AGENTS.mdUpdates internal architecture docs to reflect the new bundle/env/target workflow model.
.github/workflows/release.ymlReplaces the Release Please workflow and adds jobs to upload both bundles, encrypt vaults, and deploy targets on release_created.
.github/workflows/release-please.ymlRemoves the old Release Please workflow file.
.github/workflows/deploy.ymlAdds a manual workflow_dispatch redeploy workflow that selects one target or all targets.
.github/workflows/deploy-shared.ymlRedesigns the reusable workflow interface to accept hosts, app-ref, env-ref, and non-empty app-refs.
.github/actions/build-bundle/action.ymlExtends the bundle builder to upload to an existing release and provides defaults for Flightdeck’s machinery bundle.
.github/actions/build-apps-bundle/action.ymlAdds a thin wrapper that publishes an apps/-only bundle (flightdeck-apps.zip).
.github/actions/encrypt-env/action.ymlAdds composite action to render env from vaults/*, encrypt via SOPS age, and upload to an existing release.
.github/actions/encrypt-env/README.mdDocuments usage and the new manifest format (asset, keys, apps, env).
.github/actions/encrypt-env/requirements.txtPins PyYAML for the action implementation.
.github/actions/encrypt-env/scripts/render-env.pyEnforces the new manifest schema, renders apps as APPS=..., and writes outputs for downstream steps.
.github/actions/encrypt-env/tests/test_render_env.pyUpdates/extends unit tests for the new manifest schema and APPS rendering rules.
.github/actions/load-yaml-matrix/action.ymlAdds composite action to load a directory of YAML files into a matrix + count outputs.
.github/actions/load-yaml-matrix/README.mdDocuments load-yaml-matrix behavior and filename/key validation rules.
.github/actions/load-yaml-matrix/requirements.txtPins PyYAML for the action implementation.
.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.pyImplements matrix building with duplicate-key detection and filename validation.
.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.pyAdds unit tests covering discovery, filtering, and validation failures.
.github/actions/publish-sops-env/action.ymlRemoves the superseded publish-sops-env action.
.github/actions/publish-sops-env/README.mdRemoves the superseded publish-sops-env docs.
.github/actions/discover-manifest-matrix/action.ymlRemoves the unused manifest discovery action.
.github/actions/discover-manifest-matrix/README.mdRemoves the unused manifest discovery docs.
.github/actionlint.yamlAdds actionlint ignores for newer job context properties used in deploy-shared.yml.
Suppressed comments (6)

.github/workflows/release.yml:40

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/build-apps-bundle

.github/workflows/release.yml:69

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/encrypt-env

.github/workflows/release.yml:55

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:88

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:97

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

.github/workflows/deploy.yml:27

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: $/.github/actions/build-bundle
count: ${{ steps.matrix.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: $/.github/actions/load-yaml-matrix
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.

2 participants

@ineedjet
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', '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('^' + ".*" + ' feat: deploy rybbit for rubykatzen.com through flightdeck itself by ineedjet · Pull Request #102 · rubykatzen/flightdeck · GitHub
Skip to content

feat: deploy rybbit for rubykatzen.com through flightdeck itself - #102

Merged
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit
Aug 20, 2026
Merged

feat: deploy rybbit for rubykatzen.com through flightdeck itself#102
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit

Conversation

@ineedjet

@ineedjetineedjet commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Flightdeck now uses its own release and deployment tooling to manage Rybbit for rubykatzen.com on the existing hawkeye server.

The final configuration model separates two independent concerns:

  • vaults/ describes encrypted env assets.
  • targets/ describes deployments and explicitly selects a machinery bundle, app bundles, an env asset, and SSH destinations.

The matching hawkeye.yml filenames are a convention only. The actual relationship is the explicit env_ref in the deployment target, so either collection can live in a repository without the other.

Flightdeck's own apps/ catalog is published as its own release asset (flightdeck-apps.zip), merged into a deploy the same way as any other app bundle. There's no special-cased "core" bundle that apps ride along with implicitly — a target must list every app bundle it wants, flightdeck's own included.

Configuration

vaults/hawkeye.yml defines:

  • the hawkeye.sops.env release asset;
  • the hawkeye age recipient;
  • the plain app list (traefik, rybbit), rendered as APPS=traefik,rybbit;
  • mappings from dotenv names to GitHub Secrets or Variables.

targets/hawkeye.yml defines:

  • machinery bundle: rubykatzen/flightdeck@latest;
  • app bundles (app_refs, required, non-empty): rubykatzen/flightdeck@latest;
  • env asset: rubykatzen/flightdeck@latest:hawkeye.sops.env;
  • SSH destination: rubykatzen-com@100.75.50.2;
  • names of the GitHub credentials needed for SSH and optional Tailscale access.

Credential values are never stored in target files.

Automation

  • Added build-bundle, which builds and uploads a bundle to an existing release; paths/bundle-name default to Flightdeck's own machinery bundle (flightdeck.zip) but are fully overridable.
  • Added build-apps-bundle, a thin defaults wrapper around build-bundle (paths: apps, bundle-name: flightdeck-apps.zip) used both for flightdeck's own apps/ catalog and any consumer repo's own app bundle.
  • Replaced publish-sops-env with encrypt-env, which renders a flat config from a vaults/ manifest, encrypts it with SOPS age recipients, and uploads it to an existing release.
  • Added load-yaml-matrix, a schema-agnostic action that reads every YAML file in a caller-given directory into a GitHub Actions matrix (no directory default, no validation — callers own their manifest shape).
  • Removed the unused manifest discovery action and the asset-oriented reusable workflows; release jobs call the composite actions directly.
  • Renamed the Release Please workflow to release.yml. A created release uploads flightdeck.zip and flightdeck-apps.zip, processes every config in vaults/, and deploys every config in targets/ — all gated on release_created, so an ordinary merge to main that doesn't cut a release does nothing.
  • Added deploy.yml for manual redeploys only (workflow_dispatch, select a target or "all"). It is deliberately not triggered automatically — an earlier version fired on every completed Release workflow run regardless of whether a release was actually created, which would have redeployed hawkeye (and bounced traefik/rybbit) on every ordinary merge to main.

Deployment Interface

deploy-shared.yml exposes deployment vocabulary rather than Ansible implementation details:

  • hosts are JSON arrays of user@host SSH destinations;
  • the machinery bundle, env asset, and app bundles use complete owner/repo@tag[:asset] refs; app-refs is a required, non-empty JSON array;
  • path, retention, and the SOPS age key path have defaults and remain overridable;
  • user@host values are converted to an internal Ansible JSON inventory;
  • ~ paths are expanded per SSH user on each target host.

The default deployment path is ~/flightdeck. The default age key path is ~/.config/sops/age/keys.txt, matching the normal Linux SOPS location for the connecting user while still working under Ansible privilege escalation.

External Prerequisites

  • GitHub Secret DEPLOY_SSH_PRIVATE_KEY.
  • GitHub Variable TAILSCALE_OAUTH_CLIENT_ID and Secret TAILSCALE_OAUTH_SECRET when Tailscale is used.
  • All GitHub Secret/Variable names referenced by vaults/hawkeye.yml.
  • The server-side rubykatzen-com user, corresponding SSH access and age private key, Docker, GitHub CLI, SOPS, and the external Cloudflare Tunnel configuration.

Verification

  • 9 load-yaml-matrix unit tests
  • 8 encrypt-env unit tests
  • Actual encrypt and deploy matrix generation, traced against the real vaults/hawkeye.yml and targets/hawkeye.yml
  • Generated inventory parsed by ansible-inventory
  • ansible-playbook --syntax-check ansible/deploy.yml
  • pre-commit run --all-files
  • GitHub Embedder, GitHub config, and lint checks
  • Publish the first real Hawkeye env asset after external prerequisites are configured
  • Run the first real Hawkeye deployment

ineedjetand others added 30 commits August 18, 2026 21:27
First real (non-CI-test) self-consumption of deploy-shared.yml:
flightdeck deploys its own rybbit instance at rybbit.rubykatzen.com,
via an isolated rubykatzen-com user on the existing hawkeye host
(Tailscale IP 100.75.50.2), separate from dupmachine's own services
on that box (own traefik on 8080/8443 to avoid the port conflict,
own age keypair, own env manifest).
- projects/rubykatzen/rubykatzen-com.yml: publish-sops-env manifest
(APPS=traefik,rybbit, Cloudflare DNS challenge cert resolver).
- keys/rubykatzen-com.pub: age recipient for this deployment's
encrypted env. Private key generated locally, not committed --
goes on the server at flightdeck_sops_age_key_file, out of band.
- publish-rubykatzen-com-env.yml: renders + encrypts the manifest,
publishes it to the rubykatzen-com-config release tag.
- deploy-rubykatzen-com.yml: calls deploy-shared.yml@v0.3.0 (pinned,
same as a real external consumer would) against 100.75.50.2.
Blocked on, not part of this diff: DEPLOY_SSH_PRIVATE_KEY secret,
TAILSCALE_OAUTH_CLIENT_ID/SECRET, RUBYKATZEN_COM_CLOUDFLARE_TOKEN
secret, and server-side prep (create rubykatzen-com user, install the
age private key, cloudflared tunnel). workflow_dispatch-only until
those land.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wkeye.yml
The manifest identity is the server + isolated user + age key it
decrypts on (hawkeye), not the domain/service currently running
there (rubykatzen.com/rybbit) or a repo-grouping level that doesn't
apply to self-hosting flightdeck's own deploys. Matches the existing
convention elsewhere of naming manifests/assets after the server
(mainframe, hawkeye), and drops the redundant projects/rubykatzen/
nesting -- one flat targets/ directory, one file per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the targets/ rename: the age key, release tag, and
asset identify the deploy target (server + isolated user + key), not
the service currently running there. Renamed rubykatzen-com.pub ->
hawkeye.pub, rubykatzen-com-config -> hawkeye-config,
rubykatzen-com.sops.env -> hawkeye.sops.env. The RUBYKATZEN_COM_*
GitHub variable/secret names are untouched -- those describe the
service's own config content, not the target identity, and the
rubykatzen-com OS username/home path on the server is a separate,
correctly-still-rubykatzen-com concept.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
We were the odd one out: deploy-rubykatzen-com.yml already calls
deploy-shared.yml as a real interface, but publish-rubykatzen-com-env.yml
hand-assembled its own "ensure release exists" step around the local
publish-sops-env composite action instead of going through an
equivalent reusable workflow -- because that reusable workflow didn't
exist yet, only the composite action did.
Add publish-sops-env-shared.yml (checkout, resolve release tag --
input or read from the manifest's own release_tag, ensure the release
exists, call publish-sops-env) and rewrite our own
publish-rubykatzen-com-env.yml to just call it, matching the same
consumer interface pattern deploy-rubykatzen-com.yml already uses.
Note: this one needs `secrets: inherit` on the caller, unlike
deploy-shared.yml's explicit named secrets -- the manifest can
reference an arbitrary, per-target set of secret/variable names, so
there's no fixed schema to declare individually.
Pinned to @main for now since this file doesn't exist in any released
tag yet; re-pin to a real version once the next release ships, same
as deploy-rubykatzen-com.yml does for deploy-shared.yml@v0.3.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an interface
The old interface leaked Ansible directly to callers -- extra-vars
was a hand-written JSON blob using flightdeck_* variable names lifted
straight from ansible/deploy.yml. Callers shouldn't need to know this
runs on Ansible at all.
Replace with named inputs in plain deploy vocabulary: app-ref,
env-ref, extra-refs (comma-separated), path, keep-releases,
sops-age-key-file. The workflow builds the ansible -e JSON internally
via jq. No behavior change for existing values, just the surface.
Added defaults for the ones that have a sensible one:
- path: ~/flightdeck (matches every example/consumer so far)
- keep-releases: 5
- sops-age-key-file: ~/.config/sops/age/keys.txt for the connecting
`user`, computed rather than hardcoded since it depends on which
user connects -- can't be a static workflow_call default.
We have no external consumers of this interface yet to break, so
doing this now rather than carrying the leaky version forward.
Also updates our own deploy-rubykatzen-com.yml to the new interface
(drops explicit path/keep-releases/sops-age-key-file entirely since
the new defaults already match what we were passing), and README's
deploy-shared.yml documentation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the earlier targets/keys/release-tag rename: these
workflows deploy a whole target's APPS list, not one specific
service. If hawkeye ever hosts a second app alongside rybbit, it's
the same workflow, same target -- naming it after rubykatzen.com
(the service currently there) would become misleading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One deploy entry point for the whole repo instead of one workflow
file per target. Each target is its own job (currently just
hawkeye); a workflow_dispatch `target` input picks which job(s) run
-- "all" (default) runs every target, a specific name runs just that
job. This is also the pattern we want client repos to copy: a system
that knows exactly which target it just pushed config for can
`gh workflow run deploy.yml -f target=<name>` instead of redeploying
the whole fleet.
No scheduling added yet -- that depends on the idempotent
reconciliation work in #100/#101 landing first, otherwise a cron
trigger would just force-restart everything on every tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fter it
- Rename release-please.yml -> release.yml (name: Release Please ->
Release), matching the file.
- deploy.yml now also fires on workflow_run of Release (in addition
to manual workflow_dispatch). Auto-redeploys hawkeye whenever
Release completes successfully -- including no-op runs where
release-please found nothing to release, since workflow_run
doesn't expose that workflow's own job outputs. Accepted as an MVP
tradeoff: harmless once deploy.sh is idempotent (#100), just an
unnecessary restart until then.
- Fixed a real bug this surfaced: deploy-shared.yml's app-ref input
was reused as both the checkout ref (needs a real git ref: branch,
tag, SHA) and the flightdeck_app_ref bundle to deploy (can be
'latest', a GitHub Releases API concept, not a git ref). Passing
app-ref: latest previously would have broken the checkout step.
Split "Resolve checkout ref" (always derived from workflow_ref,
used only for actions/checkout) from app-ref resolution inside
"Build extra-vars" (input override, defaulting to the checkout ref
if not given). deploy.yml's hawkeye job now passes app-ref: latest
so it always deploys the newest release regardless of which ref
runs the playbook mechanism itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No external consumers yet, so no reason to carry the extra branch:
"if app-ref is empty, reuse whatever ref runs the playbook mechanism"
was solving a DRY concern (avoid writing the version twice) at the
cost of conflating two genuinely different things -- which ref checks
out ansible/deploy.yml vs which release gets deployed. Now that we
need them to diverge (app-ref: latest while pinned to a stable
mechanism version), the implicit default is just a trap. Require
app-ref explicitly instead; deploy.yml already passes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml's upload job was hand-assembled from checkout +
build-bundle + gh release upload steps directly, the same pattern we
already moved away from for publish-sops-env. Extract it into a
generic reusable workflow: checkout a ref, build a zip via the
build-bundle composite action, upload it to an existing release.
release.yml's own upload job now just calls it via a local path
(./.github/workflows/upload-bundle-shared.yml) -- same-repo
self-reference, no ref-pinning concern since it always runs from
whatever commit release.yml itself runs from.
Also generic enough for external use: a consumer repo packaging its
own apps/ directory as an extra bundle for flightdeck_extra_refs can
call it the same way (checkout defaults to the caller's own repo,
same as any reusable workflow without an explicit repository:
override).
Docs: added README sections for both upload-bundle-shared.yml and
publish-sops-env-shared.yml (the latter existed but was never
documented), updated the GitHub Actions intro sentence to list all
three reusable workflows, and fixed a stale release-please.yml
filename left over in the project tree from the earlier rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f upload-apps-shared.yml
upload-bundle-shared.yml took bundle-name/paths as caller inputs, but
in practice there's exactly one caller (this repo's own release.yml)
with fixed, well-known content -- flightdeck.zip is core scripts +
apps/ + README, not something that varies per call. Generic
parameterization here just pushed config a consumer shouldn't need to
know onto the call site. Baked it in; the workflow now only takes
ref/release-tag.
Split the actual generic use case -- a consumer repo bundling its own
apps/ as an extra bundle for flightdeck_extra_refs -- into its own
purpose-built upload-apps-shared.yml (fixed to apps.zip / apps/ only).
Added a comment at release.yml's upload job pointing consumers at it,
so reading our own release flow makes the intended split obvious
rather than inviting a copy-paste of flightdeck-specific config.
Also: switched every local composite-action reference inside these
shared workflows (publish-sops-env-shared.yml, upload-bundle-shared.yml,
upload-apps-shared.yml) from ./.github/actions/x to $/.github/actions/x
-- GitHub's new (July 2026) self-repository syntax. ./ inside a
reusable workflow resolves against the CALLER's repo, which is wrong
here and would have broken the moment an external repo actually called
publish-sops-env-shared.yml or upload-apps-shared.yml (our own usage
so far has been same-repo, so this was latent). $/ always resolves
against the workflow's own defining repo at the exact ref running,
regardless of caller -- matches the pattern rubykatzen/baseline
already uses in lint-shared.yml.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… a separate trigger
publish-hawkeye-env.yml had its own independent trigger (push when
targets/hawkeye.yml or keys/hawkeye.pub changed), decoupled from
whether a release actually happened. Config changes for a target are
conceptually part of the release cycle here, same as the bundle --
fold it into release.yml as another job gated on release_created,
same shape as upload. Delete the standalone workflow; a per-target
manual/path-based publish trigger isn't needed once every target's
env publish just rides along with the release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the publish-hawkeye-env.yml removal in
2b7b16a -- this is the actual job that replaces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Predates the explicit per-target job pattern (targets/hawkeye.yml,
named jobs in release.yml/deploy.yml) this repo now actually uses --
it was built for a dynamic glob-based matrix discovery approach we
never ended up using here, and nothing in this repo calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the action removal in d1472bb.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build-bundle was the odd one out -- publish-sops-env already does
render+encrypt+upload as one action, but build-bundle only built the
zip and left the gh release upload as a separate step in each caller.
Fold upload into build-bundle itself (new release-tag/token inputs),
so upload-bundle-shared.yml and upload-apps-shared.yml both drop to
checkout + one action call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deploy.yml previously fired on every completed "Release" workflow_run,
including runs where release-please created no release - which meant
every ordinary merge to main would redeploy hawkeye and bounce
traefik/rybbit. Move the automatic deploy into release.yml, gated on
release_created like upload/targets/encrypt, and leave deploy.yml with
only the manual workflow_dispatch redeploy path.
"targets" already means deploy destinations (servers). Reusing that
name for the generic config-matrix reader used by both encrypt and
deploy modes was confusing. Rename the action to load-matrix, its
name-selector input/flag from target to name, and the matrix item's
target field to name. Also rename release.yml's encrypt-mode job from
targets to encrypt-configs so it doesn't collide semantically with the
new deploy-targets job.
Rename load-matrix to load-yaml-matrix and strip out its encrypt/deploy
schema knowledge. It now just reads every YAML file in a directory,
merges each file's top-level fields with name/manifest, and builds a
matrix - no mode, no field validation, no defaults.
encrypt-env already re-parses and validates its own manifest from the
file path, so the encrypt/ path loses nothing. The targets/ path had
no other validator, so move what it relied on into the callers:
release.yml and deploy.yml now apply the path/keep-releases/sops-age-
key-file defaults inline and read credentials.secrets/credentials.variables
directly off the raw matrix item instead of the flattened fields
load-matrix used to produce.
This drops schema validation entirely (SSH destination format, ref
format, duplicate hosts, unknown-key typos) - a malformed targets/*.yml
now only surfaces once deploy-shared.yml or ansible/deploy.yml actually
runs against production hosts, not before.
targets/ is a plural noun naming what it holds (deploy targets).
encrypt/ was a singular verb, inconsistent with that pattern and with
its own contents (env asset configs, one per encrypt-env manifest).
Rename to envs/ to match.
envs/ read unnaturally next to targets/ and keys/. sops/ matches the
asset naming convention already used throughout (*.sops.env) and pairs
directly with keys/, which already holds the age recipients used to
encrypt into that directory's assets.
One repo prepares vaults from a pool of secrets/vars; different
consumers (targets) unpack the vault meant for them. Also just a
better English word for "a named container of credentials" than
sops/, which only referenced the encryption mechanism.
encrypt-configs -> encrypt-vaults, matching the deploy-targets job it
sits next to (both <verb>-<directory> now). deploy.yml's targets job
becomes deploy-targets to match release.yml's job of the same purpose,
and its step id follows the matrix convention used everywhere else.
Core apps and third-party extra apps were structurally identical
(an apps/ directory) but packaged asymmetrically: core was baked into
flightdeck.zip, extras went through a separate build-bundle+merge path.
Publish flightdeck's own apps/ as flightdeck-apps.zip via the same
build-bundle action, and merge it through the exact same ansible path
as any other app bundle - removing the special-cased "extract core zip
as the apps base" step entirely.
extra_refs is retired in favor of app_refs, now required and non-empty
throughout (targets/*.yml, deploy-shared.yml's app-refs input, and
ansible's flightdeck_app_refs): a target must explicitly list every
app bundle it wants, including flightdeck's own - there's no more
implicit apps/ riding along with the machinery bundle.
… defaults
build-bundle's paths and bundle-name now default to Flightdeck's own
machinery bundle instead of being required on every call, so the
canonical file list lives in one place (the action) instead of inline
in release.yml.
build-apps-bundle is a new, thin defaults wrapper around build-bundle
(paths: apps, bundle-name: flightdeck-apps.zip) used both for
flightdeck's own apps/ catalog and by any consumer repo publishing its
own app bundle the same way.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves Flightdeck into a self-hosting setup that can publish its own machinery/apps bundles, encrypt env assets from vaults/ manifests, and deploy targets/—including a concrete hawkeye target for running traefik + rybbit for rubykatzen.com.

Changes:

  • Introduces a vaults/ + targets/ configuration model (with hawkeye manifests) and updates docs to match.
  • Adds new composite actions (encrypt-env, load-yaml-matrix, build-apps-bundle) and updates release automation to publish both flightdeck.zip and flightdeck-apps.zip.
  • Updates the Ansible deploy flow to merge required app bundles (flightdeck_app_refs) into the release and expands ~ paths per SSH user.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
vaults/hawkeye.ymlAdds a concrete vault manifest mapping GitHub Secrets/Vars into an encrypted env asset and defining the app list.
targets/hawkeye.ymlAdds a concrete deploy target selecting bundles, env asset ref, SSH destination, and credential names.
keys/hawkeye.pubAdds the age recipient public key used by encrypt-env for hawkeye.
ansible/deploy.ymlRequires flightdeck_app_refs and merges app bundles into releases/<ts>/apps; expands ~ paths per SSH user.
README.mdDocuments the new deploy interface (flightdeck_app_refs) and the vaults/targets model + new actions/workflows.
AGENTS.mdUpdates internal architecture docs to reflect the new bundle/env/target workflow model.
.github/workflows/release.ymlReplaces the Release Please workflow and adds jobs to upload both bundles, encrypt vaults, and deploy targets on release_created.
.github/workflows/release-please.ymlRemoves the old Release Please workflow file.
.github/workflows/deploy.ymlAdds a manual workflow_dispatch redeploy workflow that selects one target or all targets.
.github/workflows/deploy-shared.ymlRedesigns the reusable workflow interface to accept hosts, app-ref, env-ref, and non-empty app-refs.
.github/actions/build-bundle/action.ymlExtends the bundle builder to upload to an existing release and provides defaults for Flightdeck’s machinery bundle.
.github/actions/build-apps-bundle/action.ymlAdds a thin wrapper that publishes an apps/-only bundle (flightdeck-apps.zip).
.github/actions/encrypt-env/action.ymlAdds composite action to render env from vaults/*, encrypt via SOPS age, and upload to an existing release.
.github/actions/encrypt-env/README.mdDocuments usage and the new manifest format (asset, keys, apps, env).
.github/actions/encrypt-env/requirements.txtPins PyYAML for the action implementation.
.github/actions/encrypt-env/scripts/render-env.pyEnforces the new manifest schema, renders apps as APPS=..., and writes outputs for downstream steps.
.github/actions/encrypt-env/tests/test_render_env.pyUpdates/extends unit tests for the new manifest schema and APPS rendering rules.
.github/actions/load-yaml-matrix/action.ymlAdds composite action to load a directory of YAML files into a matrix + count outputs.
.github/actions/load-yaml-matrix/README.mdDocuments load-yaml-matrix behavior and filename/key validation rules.
.github/actions/load-yaml-matrix/requirements.txtPins PyYAML for the action implementation.
.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.pyImplements matrix building with duplicate-key detection and filename validation.
.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.pyAdds unit tests covering discovery, filtering, and validation failures.
.github/actions/publish-sops-env/action.ymlRemoves the superseded publish-sops-env action.
.github/actions/publish-sops-env/README.mdRemoves the superseded publish-sops-env docs.
.github/actions/discover-manifest-matrix/action.ymlRemoves the unused manifest discovery action.
.github/actions/discover-manifest-matrix/README.mdRemoves the unused manifest discovery docs.
.github/actionlint.yamlAdds actionlint ignores for newer job context properties used in deploy-shared.yml.
Suppressed comments (6)

.github/workflows/release.yml:40

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/build-apps-bundle

.github/workflows/release.yml:69

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/encrypt-env

.github/workflows/release.yml:55

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:88

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:97

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

.github/workflows/deploy.yml:27

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: $/.github/actions/build-bundle
count: ${{ steps.matrix.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: $/.github/actions/load-yaml-matrix
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.

2 participants

@ineedjet
, '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" + ' feat: deploy rybbit for rubykatzen.com through flightdeck itself by ineedjet · Pull Request #102 · rubykatzen/flightdeck · GitHub
Skip to content

feat: deploy rybbit for rubykatzen.com through flightdeck itself - #102

Merged
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit
Aug 20, 2026
Merged

feat: deploy rybbit for rubykatzen.com through flightdeck itself#102
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit

Conversation

@ineedjet

@ineedjetineedjet commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Flightdeck now uses its own release and deployment tooling to manage Rybbit for rubykatzen.com on the existing hawkeye server.

The final configuration model separates two independent concerns:

  • vaults/ describes encrypted env assets.
  • targets/ describes deployments and explicitly selects a machinery bundle, app bundles, an env asset, and SSH destinations.

The matching hawkeye.yml filenames are a convention only. The actual relationship is the explicit env_ref in the deployment target, so either collection can live in a repository without the other.

Flightdeck's own apps/ catalog is published as its own release asset (flightdeck-apps.zip), merged into a deploy the same way as any other app bundle. There's no special-cased "core" bundle that apps ride along with implicitly — a target must list every app bundle it wants, flightdeck's own included.

Configuration

vaults/hawkeye.yml defines:

  • the hawkeye.sops.env release asset;
  • the hawkeye age recipient;
  • the plain app list (traefik, rybbit), rendered as APPS=traefik,rybbit;
  • mappings from dotenv names to GitHub Secrets or Variables.

targets/hawkeye.yml defines:

  • machinery bundle: rubykatzen/flightdeck@latest;
  • app bundles (app_refs, required, non-empty): rubykatzen/flightdeck@latest;
  • env asset: rubykatzen/flightdeck@latest:hawkeye.sops.env;
  • SSH destination: rubykatzen-com@100.75.50.2;
  • names of the GitHub credentials needed for SSH and optional Tailscale access.

Credential values are never stored in target files.

Automation

  • Added build-bundle, which builds and uploads a bundle to an existing release; paths/bundle-name default to Flightdeck's own machinery bundle (flightdeck.zip) but are fully overridable.
  • Added build-apps-bundle, a thin defaults wrapper around build-bundle (paths: apps, bundle-name: flightdeck-apps.zip) used both for flightdeck's own apps/ catalog and any consumer repo's own app bundle.
  • Replaced publish-sops-env with encrypt-env, which renders a flat config from a vaults/ manifest, encrypts it with SOPS age recipients, and uploads it to an existing release.
  • Added load-yaml-matrix, a schema-agnostic action that reads every YAML file in a caller-given directory into a GitHub Actions matrix (no directory default, no validation — callers own their manifest shape).
  • Removed the unused manifest discovery action and the asset-oriented reusable workflows; release jobs call the composite actions directly.
  • Renamed the Release Please workflow to release.yml. A created release uploads flightdeck.zip and flightdeck-apps.zip, processes every config in vaults/, and deploys every config in targets/ — all gated on release_created, so an ordinary merge to main that doesn't cut a release does nothing.
  • Added deploy.yml for manual redeploys only (workflow_dispatch, select a target or "all"). It is deliberately not triggered automatically — an earlier version fired on every completed Release workflow run regardless of whether a release was actually created, which would have redeployed hawkeye (and bounced traefik/rybbit) on every ordinary merge to main.

Deployment Interface

deploy-shared.yml exposes deployment vocabulary rather than Ansible implementation details:

  • hosts are JSON arrays of user@host SSH destinations;
  • the machinery bundle, env asset, and app bundles use complete owner/repo@tag[:asset] refs; app-refs is a required, non-empty JSON array;
  • path, retention, and the SOPS age key path have defaults and remain overridable;
  • user@host values are converted to an internal Ansible JSON inventory;
  • ~ paths are expanded per SSH user on each target host.

The default deployment path is ~/flightdeck. The default age key path is ~/.config/sops/age/keys.txt, matching the normal Linux SOPS location for the connecting user while still working under Ansible privilege escalation.

External Prerequisites

  • GitHub Secret DEPLOY_SSH_PRIVATE_KEY.
  • GitHub Variable TAILSCALE_OAUTH_CLIENT_ID and Secret TAILSCALE_OAUTH_SECRET when Tailscale is used.
  • All GitHub Secret/Variable names referenced by vaults/hawkeye.yml.
  • The server-side rubykatzen-com user, corresponding SSH access and age private key, Docker, GitHub CLI, SOPS, and the external Cloudflare Tunnel configuration.

Verification

  • 9 load-yaml-matrix unit tests
  • 8 encrypt-env unit tests
  • Actual encrypt and deploy matrix generation, traced against the real vaults/hawkeye.yml and targets/hawkeye.yml
  • Generated inventory parsed by ansible-inventory
  • ansible-playbook --syntax-check ansible/deploy.yml
  • pre-commit run --all-files
  • GitHub Embedder, GitHub config, and lint checks
  • Publish the first real Hawkeye env asset after external prerequisites are configured
  • Run the first real Hawkeye deployment

ineedjetand others added 30 commits August 18, 2026 21:27
First real (non-CI-test) self-consumption of deploy-shared.yml:
flightdeck deploys its own rybbit instance at rybbit.rubykatzen.com,
via an isolated rubykatzen-com user on the existing hawkeye host
(Tailscale IP 100.75.50.2), separate from dupmachine's own services
on that box (own traefik on 8080/8443 to avoid the port conflict,
own age keypair, own env manifest).
- projects/rubykatzen/rubykatzen-com.yml: publish-sops-env manifest
(APPS=traefik,rybbit, Cloudflare DNS challenge cert resolver).
- keys/rubykatzen-com.pub: age recipient for this deployment's
encrypted env. Private key generated locally, not committed --
goes on the server at flightdeck_sops_age_key_file, out of band.
- publish-rubykatzen-com-env.yml: renders + encrypts the manifest,
publishes it to the rubykatzen-com-config release tag.
- deploy-rubykatzen-com.yml: calls deploy-shared.yml@v0.3.0 (pinned,
same as a real external consumer would) against 100.75.50.2.
Blocked on, not part of this diff: DEPLOY_SSH_PRIVATE_KEY secret,
TAILSCALE_OAUTH_CLIENT_ID/SECRET, RUBYKATZEN_COM_CLOUDFLARE_TOKEN
secret, and server-side prep (create rubykatzen-com user, install the
age private key, cloudflared tunnel). workflow_dispatch-only until
those land.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wkeye.yml
The manifest identity is the server + isolated user + age key it
decrypts on (hawkeye), not the domain/service currently running
there (rubykatzen.com/rybbit) or a repo-grouping level that doesn't
apply to self-hosting flightdeck's own deploys. Matches the existing
convention elsewhere of naming manifests/assets after the server
(mainframe, hawkeye), and drops the redundant projects/rubykatzen/
nesting -- one flat targets/ directory, one file per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the targets/ rename: the age key, release tag, and
asset identify the deploy target (server + isolated user + key), not
the service currently running there. Renamed rubykatzen-com.pub ->
hawkeye.pub, rubykatzen-com-config -> hawkeye-config,
rubykatzen-com.sops.env -> hawkeye.sops.env. The RUBYKATZEN_COM_*
GitHub variable/secret names are untouched -- those describe the
service's own config content, not the target identity, and the
rubykatzen-com OS username/home path on the server is a separate,
correctly-still-rubykatzen-com concept.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
We were the odd one out: deploy-rubykatzen-com.yml already calls
deploy-shared.yml as a real interface, but publish-rubykatzen-com-env.yml
hand-assembled its own "ensure release exists" step around the local
publish-sops-env composite action instead of going through an
equivalent reusable workflow -- because that reusable workflow didn't
exist yet, only the composite action did.
Add publish-sops-env-shared.yml (checkout, resolve release tag --
input or read from the manifest's own release_tag, ensure the release
exists, call publish-sops-env) and rewrite our own
publish-rubykatzen-com-env.yml to just call it, matching the same
consumer interface pattern deploy-rubykatzen-com.yml already uses.
Note: this one needs `secrets: inherit` on the caller, unlike
deploy-shared.yml's explicit named secrets -- the manifest can
reference an arbitrary, per-target set of secret/variable names, so
there's no fixed schema to declare individually.
Pinned to @main for now since this file doesn't exist in any released
tag yet; re-pin to a real version once the next release ships, same
as deploy-rubykatzen-com.yml does for deploy-shared.yml@v0.3.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an interface
The old interface leaked Ansible directly to callers -- extra-vars
was a hand-written JSON blob using flightdeck_* variable names lifted
straight from ansible/deploy.yml. Callers shouldn't need to know this
runs on Ansible at all.
Replace with named inputs in plain deploy vocabulary: app-ref,
env-ref, extra-refs (comma-separated), path, keep-releases,
sops-age-key-file. The workflow builds the ansible -e JSON internally
via jq. No behavior change for existing values, just the surface.
Added defaults for the ones that have a sensible one:
- path: ~/flightdeck (matches every example/consumer so far)
- keep-releases: 5
- sops-age-key-file: ~/.config/sops/age/keys.txt for the connecting
`user`, computed rather than hardcoded since it depends on which
user connects -- can't be a static workflow_call default.
We have no external consumers of this interface yet to break, so
doing this now rather than carrying the leaky version forward.
Also updates our own deploy-rubykatzen-com.yml to the new interface
(drops explicit path/keep-releases/sops-age-key-file entirely since
the new defaults already match what we were passing), and README's
deploy-shared.yml documentation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the earlier targets/keys/release-tag rename: these
workflows deploy a whole target's APPS list, not one specific
service. If hawkeye ever hosts a second app alongside rybbit, it's
the same workflow, same target -- naming it after rubykatzen.com
(the service currently there) would become misleading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One deploy entry point for the whole repo instead of one workflow
file per target. Each target is its own job (currently just
hawkeye); a workflow_dispatch `target` input picks which job(s) run
-- "all" (default) runs every target, a specific name runs just that
job. This is also the pattern we want client repos to copy: a system
that knows exactly which target it just pushed config for can
`gh workflow run deploy.yml -f target=<name>` instead of redeploying
the whole fleet.
No scheduling added yet -- that depends on the idempotent
reconciliation work in #100/#101 landing first, otherwise a cron
trigger would just force-restart everything on every tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fter it
- Rename release-please.yml -> release.yml (name: Release Please ->
Release), matching the file.
- deploy.yml now also fires on workflow_run of Release (in addition
to manual workflow_dispatch). Auto-redeploys hawkeye whenever
Release completes successfully -- including no-op runs where
release-please found nothing to release, since workflow_run
doesn't expose that workflow's own job outputs. Accepted as an MVP
tradeoff: harmless once deploy.sh is idempotent (#100), just an
unnecessary restart until then.
- Fixed a real bug this surfaced: deploy-shared.yml's app-ref input
was reused as both the checkout ref (needs a real git ref: branch,
tag, SHA) and the flightdeck_app_ref bundle to deploy (can be
'latest', a GitHub Releases API concept, not a git ref). Passing
app-ref: latest previously would have broken the checkout step.
Split "Resolve checkout ref" (always derived from workflow_ref,
used only for actions/checkout) from app-ref resolution inside
"Build extra-vars" (input override, defaulting to the checkout ref
if not given). deploy.yml's hawkeye job now passes app-ref: latest
so it always deploys the newest release regardless of which ref
runs the playbook mechanism itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No external consumers yet, so no reason to carry the extra branch:
"if app-ref is empty, reuse whatever ref runs the playbook mechanism"
was solving a DRY concern (avoid writing the version twice) at the
cost of conflating two genuinely different things -- which ref checks
out ansible/deploy.yml vs which release gets deployed. Now that we
need them to diverge (app-ref: latest while pinned to a stable
mechanism version), the implicit default is just a trap. Require
app-ref explicitly instead; deploy.yml already passes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml's upload job was hand-assembled from checkout +
build-bundle + gh release upload steps directly, the same pattern we
already moved away from for publish-sops-env. Extract it into a
generic reusable workflow: checkout a ref, build a zip via the
build-bundle composite action, upload it to an existing release.
release.yml's own upload job now just calls it via a local path
(./.github/workflows/upload-bundle-shared.yml) -- same-repo
self-reference, no ref-pinning concern since it always runs from
whatever commit release.yml itself runs from.
Also generic enough for external use: a consumer repo packaging its
own apps/ directory as an extra bundle for flightdeck_extra_refs can
call it the same way (checkout defaults to the caller's own repo,
same as any reusable workflow without an explicit repository:
override).
Docs: added README sections for both upload-bundle-shared.yml and
publish-sops-env-shared.yml (the latter existed but was never
documented), updated the GitHub Actions intro sentence to list all
three reusable workflows, and fixed a stale release-please.yml
filename left over in the project tree from the earlier rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f upload-apps-shared.yml
upload-bundle-shared.yml took bundle-name/paths as caller inputs, but
in practice there's exactly one caller (this repo's own release.yml)
with fixed, well-known content -- flightdeck.zip is core scripts +
apps/ + README, not something that varies per call. Generic
parameterization here just pushed config a consumer shouldn't need to
know onto the call site. Baked it in; the workflow now only takes
ref/release-tag.
Split the actual generic use case -- a consumer repo bundling its own
apps/ as an extra bundle for flightdeck_extra_refs -- into its own
purpose-built upload-apps-shared.yml (fixed to apps.zip / apps/ only).
Added a comment at release.yml's upload job pointing consumers at it,
so reading our own release flow makes the intended split obvious
rather than inviting a copy-paste of flightdeck-specific config.
Also: switched every local composite-action reference inside these
shared workflows (publish-sops-env-shared.yml, upload-bundle-shared.yml,
upload-apps-shared.yml) from ./.github/actions/x to $/.github/actions/x
-- GitHub's new (July 2026) self-repository syntax. ./ inside a
reusable workflow resolves against the CALLER's repo, which is wrong
here and would have broken the moment an external repo actually called
publish-sops-env-shared.yml or upload-apps-shared.yml (our own usage
so far has been same-repo, so this was latent). $/ always resolves
against the workflow's own defining repo at the exact ref running,
regardless of caller -- matches the pattern rubykatzen/baseline
already uses in lint-shared.yml.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… a separate trigger
publish-hawkeye-env.yml had its own independent trigger (push when
targets/hawkeye.yml or keys/hawkeye.pub changed), decoupled from
whether a release actually happened. Config changes for a target are
conceptually part of the release cycle here, same as the bundle --
fold it into release.yml as another job gated on release_created,
same shape as upload. Delete the standalone workflow; a per-target
manual/path-based publish trigger isn't needed once every target's
env publish just rides along with the release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the publish-hawkeye-env.yml removal in
2b7b16a -- this is the actual job that replaces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Predates the explicit per-target job pattern (targets/hawkeye.yml,
named jobs in release.yml/deploy.yml) this repo now actually uses --
it was built for a dynamic glob-based matrix discovery approach we
never ended up using here, and nothing in this repo calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the action removal in d1472bb.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build-bundle was the odd one out -- publish-sops-env already does
render+encrypt+upload as one action, but build-bundle only built the
zip and left the gh release upload as a separate step in each caller.
Fold upload into build-bundle itself (new release-tag/token inputs),
so upload-bundle-shared.yml and upload-apps-shared.yml both drop to
checkout + one action call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deploy.yml previously fired on every completed "Release" workflow_run,
including runs where release-please created no release - which meant
every ordinary merge to main would redeploy hawkeye and bounce
traefik/rybbit. Move the automatic deploy into release.yml, gated on
release_created like upload/targets/encrypt, and leave deploy.yml with
only the manual workflow_dispatch redeploy path.
"targets" already means deploy destinations (servers). Reusing that
name for the generic config-matrix reader used by both encrypt and
deploy modes was confusing. Rename the action to load-matrix, its
name-selector input/flag from target to name, and the matrix item's
target field to name. Also rename release.yml's encrypt-mode job from
targets to encrypt-configs so it doesn't collide semantically with the
new deploy-targets job.
Rename load-matrix to load-yaml-matrix and strip out its encrypt/deploy
schema knowledge. It now just reads every YAML file in a directory,
merges each file's top-level fields with name/manifest, and builds a
matrix - no mode, no field validation, no defaults.
encrypt-env already re-parses and validates its own manifest from the
file path, so the encrypt/ path loses nothing. The targets/ path had
no other validator, so move what it relied on into the callers:
release.yml and deploy.yml now apply the path/keep-releases/sops-age-
key-file defaults inline and read credentials.secrets/credentials.variables
directly off the raw matrix item instead of the flattened fields
load-matrix used to produce.
This drops schema validation entirely (SSH destination format, ref
format, duplicate hosts, unknown-key typos) - a malformed targets/*.yml
now only surfaces once deploy-shared.yml or ansible/deploy.yml actually
runs against production hosts, not before.
targets/ is a plural noun naming what it holds (deploy targets).
encrypt/ was a singular verb, inconsistent with that pattern and with
its own contents (env asset configs, one per encrypt-env manifest).
Rename to envs/ to match.
envs/ read unnaturally next to targets/ and keys/. sops/ matches the
asset naming convention already used throughout (*.sops.env) and pairs
directly with keys/, which already holds the age recipients used to
encrypt into that directory's assets.
One repo prepares vaults from a pool of secrets/vars; different
consumers (targets) unpack the vault meant for them. Also just a
better English word for "a named container of credentials" than
sops/, which only referenced the encryption mechanism.
encrypt-configs -> encrypt-vaults, matching the deploy-targets job it
sits next to (both <verb>-<directory> now). deploy.yml's targets job
becomes deploy-targets to match release.yml's job of the same purpose,
and its step id follows the matrix convention used everywhere else.
Core apps and third-party extra apps were structurally identical
(an apps/ directory) but packaged asymmetrically: core was baked into
flightdeck.zip, extras went through a separate build-bundle+merge path.
Publish flightdeck's own apps/ as flightdeck-apps.zip via the same
build-bundle action, and merge it through the exact same ansible path
as any other app bundle - removing the special-cased "extract core zip
as the apps base" step entirely.
extra_refs is retired in favor of app_refs, now required and non-empty
throughout (targets/*.yml, deploy-shared.yml's app-refs input, and
ansible's flightdeck_app_refs): a target must explicitly list every
app bundle it wants, including flightdeck's own - there's no more
implicit apps/ riding along with the machinery bundle.
… defaults
build-bundle's paths and bundle-name now default to Flightdeck's own
machinery bundle instead of being required on every call, so the
canonical file list lives in one place (the action) instead of inline
in release.yml.
build-apps-bundle is a new, thin defaults wrapper around build-bundle
(paths: apps, bundle-name: flightdeck-apps.zip) used both for
flightdeck's own apps/ catalog and by any consumer repo publishing its
own app bundle the same way.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves Flightdeck into a self-hosting setup that can publish its own machinery/apps bundles, encrypt env assets from vaults/ manifests, and deploy targets/—including a concrete hawkeye target for running traefik + rybbit for rubykatzen.com.

Changes:

  • Introduces a vaults/ + targets/ configuration model (with hawkeye manifests) and updates docs to match.
  • Adds new composite actions (encrypt-env, load-yaml-matrix, build-apps-bundle) and updates release automation to publish both flightdeck.zip and flightdeck-apps.zip.
  • Updates the Ansible deploy flow to merge required app bundles (flightdeck_app_refs) into the release and expands ~ paths per SSH user.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
vaults/hawkeye.ymlAdds a concrete vault manifest mapping GitHub Secrets/Vars into an encrypted env asset and defining the app list.
targets/hawkeye.ymlAdds a concrete deploy target selecting bundles, env asset ref, SSH destination, and credential names.
keys/hawkeye.pubAdds the age recipient public key used by encrypt-env for hawkeye.
ansible/deploy.ymlRequires flightdeck_app_refs and merges app bundles into releases/<ts>/apps; expands ~ paths per SSH user.
README.mdDocuments the new deploy interface (flightdeck_app_refs) and the vaults/targets model + new actions/workflows.
AGENTS.mdUpdates internal architecture docs to reflect the new bundle/env/target workflow model.
.github/workflows/release.ymlReplaces the Release Please workflow and adds jobs to upload both bundles, encrypt vaults, and deploy targets on release_created.
.github/workflows/release-please.ymlRemoves the old Release Please workflow file.
.github/workflows/deploy.ymlAdds a manual workflow_dispatch redeploy workflow that selects one target or all targets.
.github/workflows/deploy-shared.ymlRedesigns the reusable workflow interface to accept hosts, app-ref, env-ref, and non-empty app-refs.
.github/actions/build-bundle/action.ymlExtends the bundle builder to upload to an existing release and provides defaults for Flightdeck’s machinery bundle.
.github/actions/build-apps-bundle/action.ymlAdds a thin wrapper that publishes an apps/-only bundle (flightdeck-apps.zip).
.github/actions/encrypt-env/action.ymlAdds composite action to render env from vaults/*, encrypt via SOPS age, and upload to an existing release.
.github/actions/encrypt-env/README.mdDocuments usage and the new manifest format (asset, keys, apps, env).
.github/actions/encrypt-env/requirements.txtPins PyYAML for the action implementation.
.github/actions/encrypt-env/scripts/render-env.pyEnforces the new manifest schema, renders apps as APPS=..., and writes outputs for downstream steps.
.github/actions/encrypt-env/tests/test_render_env.pyUpdates/extends unit tests for the new manifest schema and APPS rendering rules.
.github/actions/load-yaml-matrix/action.ymlAdds composite action to load a directory of YAML files into a matrix + count outputs.
.github/actions/load-yaml-matrix/README.mdDocuments load-yaml-matrix behavior and filename/key validation rules.
.github/actions/load-yaml-matrix/requirements.txtPins PyYAML for the action implementation.
.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.pyImplements matrix building with duplicate-key detection and filename validation.
.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.pyAdds unit tests covering discovery, filtering, and validation failures.
.github/actions/publish-sops-env/action.ymlRemoves the superseded publish-sops-env action.
.github/actions/publish-sops-env/README.mdRemoves the superseded publish-sops-env docs.
.github/actions/discover-manifest-matrix/action.ymlRemoves the unused manifest discovery action.
.github/actions/discover-manifest-matrix/README.mdRemoves the unused manifest discovery docs.
.github/actionlint.yamlAdds actionlint ignores for newer job context properties used in deploy-shared.yml.
Suppressed comments (6)

.github/workflows/release.yml:40

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/build-apps-bundle

.github/workflows/release.yml:69

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/encrypt-env

.github/workflows/release.yml:55

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:88

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:97

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

.github/workflows/deploy.yml:27

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: $/.github/actions/build-bundle
count: ${{ steps.matrix.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: $/.github/actions/load-yaml-matrix
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.

2 participants

@ineedjet
, '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('^' + ".*" + ' feat: deploy rybbit for rubykatzen.com through flightdeck itself by ineedjet · Pull Request #102 · rubykatzen/flightdeck · GitHub
Skip to content

feat: deploy rybbit for rubykatzen.com through flightdeck itself - #102

Merged
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit
Aug 20, 2026
Merged

feat: deploy rybbit for rubykatzen.com through flightdeck itself#102
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit

Conversation

@ineedjet

@ineedjetineedjet commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Flightdeck now uses its own release and deployment tooling to manage Rybbit for rubykatzen.com on the existing hawkeye server.

The final configuration model separates two independent concerns:

  • vaults/ describes encrypted env assets.
  • targets/ describes deployments and explicitly selects a machinery bundle, app bundles, an env asset, and SSH destinations.

The matching hawkeye.yml filenames are a convention only. The actual relationship is the explicit env_ref in the deployment target, so either collection can live in a repository without the other.

Flightdeck's own apps/ catalog is published as its own release asset (flightdeck-apps.zip), merged into a deploy the same way as any other app bundle. There's no special-cased "core" bundle that apps ride along with implicitly — a target must list every app bundle it wants, flightdeck's own included.

Configuration

vaults/hawkeye.yml defines:

  • the hawkeye.sops.env release asset;
  • the hawkeye age recipient;
  • the plain app list (traefik, rybbit), rendered as APPS=traefik,rybbit;
  • mappings from dotenv names to GitHub Secrets or Variables.

targets/hawkeye.yml defines:

  • machinery bundle: rubykatzen/flightdeck@latest;
  • app bundles (app_refs, required, non-empty): rubykatzen/flightdeck@latest;
  • env asset: rubykatzen/flightdeck@latest:hawkeye.sops.env;
  • SSH destination: rubykatzen-com@100.75.50.2;
  • names of the GitHub credentials needed for SSH and optional Tailscale access.

Credential values are never stored in target files.

Automation

  • Added build-bundle, which builds and uploads a bundle to an existing release; paths/bundle-name default to Flightdeck's own machinery bundle (flightdeck.zip) but are fully overridable.
  • Added build-apps-bundle, a thin defaults wrapper around build-bundle (paths: apps, bundle-name: flightdeck-apps.zip) used both for flightdeck's own apps/ catalog and any consumer repo's own app bundle.
  • Replaced publish-sops-env with encrypt-env, which renders a flat config from a vaults/ manifest, encrypts it with SOPS age recipients, and uploads it to an existing release.
  • Added load-yaml-matrix, a schema-agnostic action that reads every YAML file in a caller-given directory into a GitHub Actions matrix (no directory default, no validation — callers own their manifest shape).
  • Removed the unused manifest discovery action and the asset-oriented reusable workflows; release jobs call the composite actions directly.
  • Renamed the Release Please workflow to release.yml. A created release uploads flightdeck.zip and flightdeck-apps.zip, processes every config in vaults/, and deploys every config in targets/ — all gated on release_created, so an ordinary merge to main that doesn't cut a release does nothing.
  • Added deploy.yml for manual redeploys only (workflow_dispatch, select a target or "all"). It is deliberately not triggered automatically — an earlier version fired on every completed Release workflow run regardless of whether a release was actually created, which would have redeployed hawkeye (and bounced traefik/rybbit) on every ordinary merge to main.

Deployment Interface

deploy-shared.yml exposes deployment vocabulary rather than Ansible implementation details:

  • hosts are JSON arrays of user@host SSH destinations;
  • the machinery bundle, env asset, and app bundles use complete owner/repo@tag[:asset] refs; app-refs is a required, non-empty JSON array;
  • path, retention, and the SOPS age key path have defaults and remain overridable;
  • user@host values are converted to an internal Ansible JSON inventory;
  • ~ paths are expanded per SSH user on each target host.

The default deployment path is ~/flightdeck. The default age key path is ~/.config/sops/age/keys.txt, matching the normal Linux SOPS location for the connecting user while still working under Ansible privilege escalation.

External Prerequisites

  • GitHub Secret DEPLOY_SSH_PRIVATE_KEY.
  • GitHub Variable TAILSCALE_OAUTH_CLIENT_ID and Secret TAILSCALE_OAUTH_SECRET when Tailscale is used.
  • All GitHub Secret/Variable names referenced by vaults/hawkeye.yml.
  • The server-side rubykatzen-com user, corresponding SSH access and age private key, Docker, GitHub CLI, SOPS, and the external Cloudflare Tunnel configuration.

Verification

  • 9 load-yaml-matrix unit tests
  • 8 encrypt-env unit tests
  • Actual encrypt and deploy matrix generation, traced against the real vaults/hawkeye.yml and targets/hawkeye.yml
  • Generated inventory parsed by ansible-inventory
  • ansible-playbook --syntax-check ansible/deploy.yml
  • pre-commit run --all-files
  • GitHub Embedder, GitHub config, and lint checks
  • Publish the first real Hawkeye env asset after external prerequisites are configured
  • Run the first real Hawkeye deployment

ineedjetand others added 30 commits August 18, 2026 21:27
First real (non-CI-test) self-consumption of deploy-shared.yml:
flightdeck deploys its own rybbit instance at rybbit.rubykatzen.com,
via an isolated rubykatzen-com user on the existing hawkeye host
(Tailscale IP 100.75.50.2), separate from dupmachine's own services
on that box (own traefik on 8080/8443 to avoid the port conflict,
own age keypair, own env manifest).
- projects/rubykatzen/rubykatzen-com.yml: publish-sops-env manifest
(APPS=traefik,rybbit, Cloudflare DNS challenge cert resolver).
- keys/rubykatzen-com.pub: age recipient for this deployment's
encrypted env. Private key generated locally, not committed --
goes on the server at flightdeck_sops_age_key_file, out of band.
- publish-rubykatzen-com-env.yml: renders + encrypts the manifest,
publishes it to the rubykatzen-com-config release tag.
- deploy-rubykatzen-com.yml: calls deploy-shared.yml@v0.3.0 (pinned,
same as a real external consumer would) against 100.75.50.2.
Blocked on, not part of this diff: DEPLOY_SSH_PRIVATE_KEY secret,
TAILSCALE_OAUTH_CLIENT_ID/SECRET, RUBYKATZEN_COM_CLOUDFLARE_TOKEN
secret, and server-side prep (create rubykatzen-com user, install the
age private key, cloudflared tunnel). workflow_dispatch-only until
those land.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wkeye.yml
The manifest identity is the server + isolated user + age key it
decrypts on (hawkeye), not the domain/service currently running
there (rubykatzen.com/rybbit) or a repo-grouping level that doesn't
apply to self-hosting flightdeck's own deploys. Matches the existing
convention elsewhere of naming manifests/assets after the server
(mainframe, hawkeye), and drops the redundant projects/rubykatzen/
nesting -- one flat targets/ directory, one file per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the targets/ rename: the age key, release tag, and
asset identify the deploy target (server + isolated user + key), not
the service currently running there. Renamed rubykatzen-com.pub ->
hawkeye.pub, rubykatzen-com-config -> hawkeye-config,
rubykatzen-com.sops.env -> hawkeye.sops.env. The RUBYKATZEN_COM_*
GitHub variable/secret names are untouched -- those describe the
service's own config content, not the target identity, and the
rubykatzen-com OS username/home path on the server is a separate,
correctly-still-rubykatzen-com concept.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
We were the odd one out: deploy-rubykatzen-com.yml already calls
deploy-shared.yml as a real interface, but publish-rubykatzen-com-env.yml
hand-assembled its own "ensure release exists" step around the local
publish-sops-env composite action instead of going through an
equivalent reusable workflow -- because that reusable workflow didn't
exist yet, only the composite action did.
Add publish-sops-env-shared.yml (checkout, resolve release tag --
input or read from the manifest's own release_tag, ensure the release
exists, call publish-sops-env) and rewrite our own
publish-rubykatzen-com-env.yml to just call it, matching the same
consumer interface pattern deploy-rubykatzen-com.yml already uses.
Note: this one needs `secrets: inherit` on the caller, unlike
deploy-shared.yml's explicit named secrets -- the manifest can
reference an arbitrary, per-target set of secret/variable names, so
there's no fixed schema to declare individually.
Pinned to @main for now since this file doesn't exist in any released
tag yet; re-pin to a real version once the next release ships, same
as deploy-rubykatzen-com.yml does for deploy-shared.yml@v0.3.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an interface
The old interface leaked Ansible directly to callers -- extra-vars
was a hand-written JSON blob using flightdeck_* variable names lifted
straight from ansible/deploy.yml. Callers shouldn't need to know this
runs on Ansible at all.
Replace with named inputs in plain deploy vocabulary: app-ref,
env-ref, extra-refs (comma-separated), path, keep-releases,
sops-age-key-file. The workflow builds the ansible -e JSON internally
via jq. No behavior change for existing values, just the surface.
Added defaults for the ones that have a sensible one:
- path: ~/flightdeck (matches every example/consumer so far)
- keep-releases: 5
- sops-age-key-file: ~/.config/sops/age/keys.txt for the connecting
`user`, computed rather than hardcoded since it depends on which
user connects -- can't be a static workflow_call default.
We have no external consumers of this interface yet to break, so
doing this now rather than carrying the leaky version forward.
Also updates our own deploy-rubykatzen-com.yml to the new interface
(drops explicit path/keep-releases/sops-age-key-file entirely since
the new defaults already match what we were passing), and README's
deploy-shared.yml documentation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the earlier targets/keys/release-tag rename: these
workflows deploy a whole target's APPS list, not one specific
service. If hawkeye ever hosts a second app alongside rybbit, it's
the same workflow, same target -- naming it after rubykatzen.com
(the service currently there) would become misleading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One deploy entry point for the whole repo instead of one workflow
file per target. Each target is its own job (currently just
hawkeye); a workflow_dispatch `target` input picks which job(s) run
-- "all" (default) runs every target, a specific name runs just that
job. This is also the pattern we want client repos to copy: a system
that knows exactly which target it just pushed config for can
`gh workflow run deploy.yml -f target=<name>` instead of redeploying
the whole fleet.
No scheduling added yet -- that depends on the idempotent
reconciliation work in #100/#101 landing first, otherwise a cron
trigger would just force-restart everything on every tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fter it
- Rename release-please.yml -> release.yml (name: Release Please ->
Release), matching the file.
- deploy.yml now also fires on workflow_run of Release (in addition
to manual workflow_dispatch). Auto-redeploys hawkeye whenever
Release completes successfully -- including no-op runs where
release-please found nothing to release, since workflow_run
doesn't expose that workflow's own job outputs. Accepted as an MVP
tradeoff: harmless once deploy.sh is idempotent (#100), just an
unnecessary restart until then.
- Fixed a real bug this surfaced: deploy-shared.yml's app-ref input
was reused as both the checkout ref (needs a real git ref: branch,
tag, SHA) and the flightdeck_app_ref bundle to deploy (can be
'latest', a GitHub Releases API concept, not a git ref). Passing
app-ref: latest previously would have broken the checkout step.
Split "Resolve checkout ref" (always derived from workflow_ref,
used only for actions/checkout) from app-ref resolution inside
"Build extra-vars" (input override, defaulting to the checkout ref
if not given). deploy.yml's hawkeye job now passes app-ref: latest
so it always deploys the newest release regardless of which ref
runs the playbook mechanism itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No external consumers yet, so no reason to carry the extra branch:
"if app-ref is empty, reuse whatever ref runs the playbook mechanism"
was solving a DRY concern (avoid writing the version twice) at the
cost of conflating two genuinely different things -- which ref checks
out ansible/deploy.yml vs which release gets deployed. Now that we
need them to diverge (app-ref: latest while pinned to a stable
mechanism version), the implicit default is just a trap. Require
app-ref explicitly instead; deploy.yml already passes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml's upload job was hand-assembled from checkout +
build-bundle + gh release upload steps directly, the same pattern we
already moved away from for publish-sops-env. Extract it into a
generic reusable workflow: checkout a ref, build a zip via the
build-bundle composite action, upload it to an existing release.
release.yml's own upload job now just calls it via a local path
(./.github/workflows/upload-bundle-shared.yml) -- same-repo
self-reference, no ref-pinning concern since it always runs from
whatever commit release.yml itself runs from.
Also generic enough for external use: a consumer repo packaging its
own apps/ directory as an extra bundle for flightdeck_extra_refs can
call it the same way (checkout defaults to the caller's own repo,
same as any reusable workflow without an explicit repository:
override).
Docs: added README sections for both upload-bundle-shared.yml and
publish-sops-env-shared.yml (the latter existed but was never
documented), updated the GitHub Actions intro sentence to list all
three reusable workflows, and fixed a stale release-please.yml
filename left over in the project tree from the earlier rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f upload-apps-shared.yml
upload-bundle-shared.yml took bundle-name/paths as caller inputs, but
in practice there's exactly one caller (this repo's own release.yml)
with fixed, well-known content -- flightdeck.zip is core scripts +
apps/ + README, not something that varies per call. Generic
parameterization here just pushed config a consumer shouldn't need to
know onto the call site. Baked it in; the workflow now only takes
ref/release-tag.
Split the actual generic use case -- a consumer repo bundling its own
apps/ as an extra bundle for flightdeck_extra_refs -- into its own
purpose-built upload-apps-shared.yml (fixed to apps.zip / apps/ only).
Added a comment at release.yml's upload job pointing consumers at it,
so reading our own release flow makes the intended split obvious
rather than inviting a copy-paste of flightdeck-specific config.
Also: switched every local composite-action reference inside these
shared workflows (publish-sops-env-shared.yml, upload-bundle-shared.yml,
upload-apps-shared.yml) from ./.github/actions/x to $/.github/actions/x
-- GitHub's new (July 2026) self-repository syntax. ./ inside a
reusable workflow resolves against the CALLER's repo, which is wrong
here and would have broken the moment an external repo actually called
publish-sops-env-shared.yml or upload-apps-shared.yml (our own usage
so far has been same-repo, so this was latent). $/ always resolves
against the workflow's own defining repo at the exact ref running,
regardless of caller -- matches the pattern rubykatzen/baseline
already uses in lint-shared.yml.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… a separate trigger
publish-hawkeye-env.yml had its own independent trigger (push when
targets/hawkeye.yml or keys/hawkeye.pub changed), decoupled from
whether a release actually happened. Config changes for a target are
conceptually part of the release cycle here, same as the bundle --
fold it into release.yml as another job gated on release_created,
same shape as upload. Delete the standalone workflow; a per-target
manual/path-based publish trigger isn't needed once every target's
env publish just rides along with the release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the publish-hawkeye-env.yml removal in
2b7b16a -- this is the actual job that replaces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Predates the explicit per-target job pattern (targets/hawkeye.yml,
named jobs in release.yml/deploy.yml) this repo now actually uses --
it was built for a dynamic glob-based matrix discovery approach we
never ended up using here, and nothing in this repo calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the action removal in d1472bb.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build-bundle was the odd one out -- publish-sops-env already does
render+encrypt+upload as one action, but build-bundle only built the
zip and left the gh release upload as a separate step in each caller.
Fold upload into build-bundle itself (new release-tag/token inputs),
so upload-bundle-shared.yml and upload-apps-shared.yml both drop to
checkout + one action call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deploy.yml previously fired on every completed "Release" workflow_run,
including runs where release-please created no release - which meant
every ordinary merge to main would redeploy hawkeye and bounce
traefik/rybbit. Move the automatic deploy into release.yml, gated on
release_created like upload/targets/encrypt, and leave deploy.yml with
only the manual workflow_dispatch redeploy path.
"targets" already means deploy destinations (servers). Reusing that
name for the generic config-matrix reader used by both encrypt and
deploy modes was confusing. Rename the action to load-matrix, its
name-selector input/flag from target to name, and the matrix item's
target field to name. Also rename release.yml's encrypt-mode job from
targets to encrypt-configs so it doesn't collide semantically with the
new deploy-targets job.
Rename load-matrix to load-yaml-matrix and strip out its encrypt/deploy
schema knowledge. It now just reads every YAML file in a directory,
merges each file's top-level fields with name/manifest, and builds a
matrix - no mode, no field validation, no defaults.
encrypt-env already re-parses and validates its own manifest from the
file path, so the encrypt/ path loses nothing. The targets/ path had
no other validator, so move what it relied on into the callers:
release.yml and deploy.yml now apply the path/keep-releases/sops-age-
key-file defaults inline and read credentials.secrets/credentials.variables
directly off the raw matrix item instead of the flattened fields
load-matrix used to produce.
This drops schema validation entirely (SSH destination format, ref
format, duplicate hosts, unknown-key typos) - a malformed targets/*.yml
now only surfaces once deploy-shared.yml or ansible/deploy.yml actually
runs against production hosts, not before.
targets/ is a plural noun naming what it holds (deploy targets).
encrypt/ was a singular verb, inconsistent with that pattern and with
its own contents (env asset configs, one per encrypt-env manifest).
Rename to envs/ to match.
envs/ read unnaturally next to targets/ and keys/. sops/ matches the
asset naming convention already used throughout (*.sops.env) and pairs
directly with keys/, which already holds the age recipients used to
encrypt into that directory's assets.
One repo prepares vaults from a pool of secrets/vars; different
consumers (targets) unpack the vault meant for them. Also just a
better English word for "a named container of credentials" than
sops/, which only referenced the encryption mechanism.
encrypt-configs -> encrypt-vaults, matching the deploy-targets job it
sits next to (both <verb>-<directory> now). deploy.yml's targets job
becomes deploy-targets to match release.yml's job of the same purpose,
and its step id follows the matrix convention used everywhere else.
Core apps and third-party extra apps were structurally identical
(an apps/ directory) but packaged asymmetrically: core was baked into
flightdeck.zip, extras went through a separate build-bundle+merge path.
Publish flightdeck's own apps/ as flightdeck-apps.zip via the same
build-bundle action, and merge it through the exact same ansible path
as any other app bundle - removing the special-cased "extract core zip
as the apps base" step entirely.
extra_refs is retired in favor of app_refs, now required and non-empty
throughout (targets/*.yml, deploy-shared.yml's app-refs input, and
ansible's flightdeck_app_refs): a target must explicitly list every
app bundle it wants, including flightdeck's own - there's no more
implicit apps/ riding along with the machinery bundle.
… defaults
build-bundle's paths and bundle-name now default to Flightdeck's own
machinery bundle instead of being required on every call, so the
canonical file list lives in one place (the action) instead of inline
in release.yml.
build-apps-bundle is a new, thin defaults wrapper around build-bundle
(paths: apps, bundle-name: flightdeck-apps.zip) used both for
flightdeck's own apps/ catalog and by any consumer repo publishing its
own app bundle the same way.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves Flightdeck into a self-hosting setup that can publish its own machinery/apps bundles, encrypt env assets from vaults/ manifests, and deploy targets/—including a concrete hawkeye target for running traefik + rybbit for rubykatzen.com.

Changes:

  • Introduces a vaults/ + targets/ configuration model (with hawkeye manifests) and updates docs to match.
  • Adds new composite actions (encrypt-env, load-yaml-matrix, build-apps-bundle) and updates release automation to publish both flightdeck.zip and flightdeck-apps.zip.
  • Updates the Ansible deploy flow to merge required app bundles (flightdeck_app_refs) into the release and expands ~ paths per SSH user.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
vaults/hawkeye.ymlAdds a concrete vault manifest mapping GitHub Secrets/Vars into an encrypted env asset and defining the app list.
targets/hawkeye.ymlAdds a concrete deploy target selecting bundles, env asset ref, SSH destination, and credential names.
keys/hawkeye.pubAdds the age recipient public key used by encrypt-env for hawkeye.
ansible/deploy.ymlRequires flightdeck_app_refs and merges app bundles into releases/<ts>/apps; expands ~ paths per SSH user.
README.mdDocuments the new deploy interface (flightdeck_app_refs) and the vaults/targets model + new actions/workflows.
AGENTS.mdUpdates internal architecture docs to reflect the new bundle/env/target workflow model.
.github/workflows/release.ymlReplaces the Release Please workflow and adds jobs to upload both bundles, encrypt vaults, and deploy targets on release_created.
.github/workflows/release-please.ymlRemoves the old Release Please workflow file.
.github/workflows/deploy.ymlAdds a manual workflow_dispatch redeploy workflow that selects one target or all targets.
.github/workflows/deploy-shared.ymlRedesigns the reusable workflow interface to accept hosts, app-ref, env-ref, and non-empty app-refs.
.github/actions/build-bundle/action.ymlExtends the bundle builder to upload to an existing release and provides defaults for Flightdeck’s machinery bundle.
.github/actions/build-apps-bundle/action.ymlAdds a thin wrapper that publishes an apps/-only bundle (flightdeck-apps.zip).
.github/actions/encrypt-env/action.ymlAdds composite action to render env from vaults/*, encrypt via SOPS age, and upload to an existing release.
.github/actions/encrypt-env/README.mdDocuments usage and the new manifest format (asset, keys, apps, env).
.github/actions/encrypt-env/requirements.txtPins PyYAML for the action implementation.
.github/actions/encrypt-env/scripts/render-env.pyEnforces the new manifest schema, renders apps as APPS=..., and writes outputs for downstream steps.
.github/actions/encrypt-env/tests/test_render_env.pyUpdates/extends unit tests for the new manifest schema and APPS rendering rules.
.github/actions/load-yaml-matrix/action.ymlAdds composite action to load a directory of YAML files into a matrix + count outputs.
.github/actions/load-yaml-matrix/README.mdDocuments load-yaml-matrix behavior and filename/key validation rules.
.github/actions/load-yaml-matrix/requirements.txtPins PyYAML for the action implementation.
.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.pyImplements matrix building with duplicate-key detection and filename validation.
.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.pyAdds unit tests covering discovery, filtering, and validation failures.
.github/actions/publish-sops-env/action.ymlRemoves the superseded publish-sops-env action.
.github/actions/publish-sops-env/README.mdRemoves the superseded publish-sops-env docs.
.github/actions/discover-manifest-matrix/action.ymlRemoves the unused manifest discovery action.
.github/actions/discover-manifest-matrix/README.mdRemoves the unused manifest discovery docs.
.github/actionlint.yamlAdds actionlint ignores for newer job context properties used in deploy-shared.yml.
Suppressed comments (6)

.github/workflows/release.yml:40

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/build-apps-bundle

.github/workflows/release.yml:69

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/encrypt-env

.github/workflows/release.yml:55

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:88

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:97

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

.github/workflows/deploy.yml:27

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: $/.github/actions/build-bundle
count: ${{ steps.matrix.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: $/.github/actions/load-yaml-matrix
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.

2 participants

@ineedjet
, '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); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: deploy rybbit for rubykatzen.com through flightdeck itself by ineedjet · Pull Request #102 · rubykatzen/flightdeck · GitHub
Skip to content

feat: deploy rybbit for rubykatzen.com through flightdeck itself - #102

Merged
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit
Aug 20, 2026
Merged

feat: deploy rybbit for rubykatzen.com through flightdeck itself#102
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit

Conversation

@ineedjet

@ineedjetineedjet commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Flightdeck now uses its own release and deployment tooling to manage Rybbit for rubykatzen.com on the existing hawkeye server.

The final configuration model separates two independent concerns:

  • vaults/ describes encrypted env assets.
  • targets/ describes deployments and explicitly selects a machinery bundle, app bundles, an env asset, and SSH destinations.

The matching hawkeye.yml filenames are a convention only. The actual relationship is the explicit env_ref in the deployment target, so either collection can live in a repository without the other.

Flightdeck's own apps/ catalog is published as its own release asset (flightdeck-apps.zip), merged into a deploy the same way as any other app bundle. There's no special-cased "core" bundle that apps ride along with implicitly — a target must list every app bundle it wants, flightdeck's own included.

Configuration

vaults/hawkeye.yml defines:

  • the hawkeye.sops.env release asset;
  • the hawkeye age recipient;
  • the plain app list (traefik, rybbit), rendered as APPS=traefik,rybbit;
  • mappings from dotenv names to GitHub Secrets or Variables.

targets/hawkeye.yml defines:

  • machinery bundle: rubykatzen/flightdeck@latest;
  • app bundles (app_refs, required, non-empty): rubykatzen/flightdeck@latest;
  • env asset: rubykatzen/flightdeck@latest:hawkeye.sops.env;
  • SSH destination: rubykatzen-com@100.75.50.2;
  • names of the GitHub credentials needed for SSH and optional Tailscale access.

Credential values are never stored in target files.

Automation

  • Added build-bundle, which builds and uploads a bundle to an existing release; paths/bundle-name default to Flightdeck's own machinery bundle (flightdeck.zip) but are fully overridable.
  • Added build-apps-bundle, a thin defaults wrapper around build-bundle (paths: apps, bundle-name: flightdeck-apps.zip) used both for flightdeck's own apps/ catalog and any consumer repo's own app bundle.
  • Replaced publish-sops-env with encrypt-env, which renders a flat config from a vaults/ manifest, encrypts it with SOPS age recipients, and uploads it to an existing release.
  • Added load-yaml-matrix, a schema-agnostic action that reads every YAML file in a caller-given directory into a GitHub Actions matrix (no directory default, no validation — callers own their manifest shape).
  • Removed the unused manifest discovery action and the asset-oriented reusable workflows; release jobs call the composite actions directly.
  • Renamed the Release Please workflow to release.yml. A created release uploads flightdeck.zip and flightdeck-apps.zip, processes every config in vaults/, and deploys every config in targets/ — all gated on release_created, so an ordinary merge to main that doesn't cut a release does nothing.
  • Added deploy.yml for manual redeploys only (workflow_dispatch, select a target or "all"). It is deliberately not triggered automatically — an earlier version fired on every completed Release workflow run regardless of whether a release was actually created, which would have redeployed hawkeye (and bounced traefik/rybbit) on every ordinary merge to main.

Deployment Interface

deploy-shared.yml exposes deployment vocabulary rather than Ansible implementation details:

  • hosts are JSON arrays of user@host SSH destinations;
  • the machinery bundle, env asset, and app bundles use complete owner/repo@tag[:asset] refs; app-refs is a required, non-empty JSON array;
  • path, retention, and the SOPS age key path have defaults and remain overridable;
  • user@host values are converted to an internal Ansible JSON inventory;
  • ~ paths are expanded per SSH user on each target host.

The default deployment path is ~/flightdeck. The default age key path is ~/.config/sops/age/keys.txt, matching the normal Linux SOPS location for the connecting user while still working under Ansible privilege escalation.

External Prerequisites

  • GitHub Secret DEPLOY_SSH_PRIVATE_KEY.
  • GitHub Variable TAILSCALE_OAUTH_CLIENT_ID and Secret TAILSCALE_OAUTH_SECRET when Tailscale is used.
  • All GitHub Secret/Variable names referenced by vaults/hawkeye.yml.
  • The server-side rubykatzen-com user, corresponding SSH access and age private key, Docker, GitHub CLI, SOPS, and the external Cloudflare Tunnel configuration.

Verification

  • 9 load-yaml-matrix unit tests
  • 8 encrypt-env unit tests
  • Actual encrypt and deploy matrix generation, traced against the real vaults/hawkeye.yml and targets/hawkeye.yml
  • Generated inventory parsed by ansible-inventory
  • ansible-playbook --syntax-check ansible/deploy.yml
  • pre-commit run --all-files
  • GitHub Embedder, GitHub config, and lint checks
  • Publish the first real Hawkeye env asset after external prerequisites are configured
  • Run the first real Hawkeye deployment

ineedjetand others added 30 commits August 18, 2026 21:27
First real (non-CI-test) self-consumption of deploy-shared.yml:
flightdeck deploys its own rybbit instance at rybbit.rubykatzen.com,
via an isolated rubykatzen-com user on the existing hawkeye host
(Tailscale IP 100.75.50.2), separate from dupmachine's own services
on that box (own traefik on 8080/8443 to avoid the port conflict,
own age keypair, own env manifest).
- projects/rubykatzen/rubykatzen-com.yml: publish-sops-env manifest
(APPS=traefik,rybbit, Cloudflare DNS challenge cert resolver).
- keys/rubykatzen-com.pub: age recipient for this deployment's
encrypted env. Private key generated locally, not committed --
goes on the server at flightdeck_sops_age_key_file, out of band.
- publish-rubykatzen-com-env.yml: renders + encrypts the manifest,
publishes it to the rubykatzen-com-config release tag.
- deploy-rubykatzen-com.yml: calls deploy-shared.yml@v0.3.0 (pinned,
same as a real external consumer would) against 100.75.50.2.
Blocked on, not part of this diff: DEPLOY_SSH_PRIVATE_KEY secret,
TAILSCALE_OAUTH_CLIENT_ID/SECRET, RUBYKATZEN_COM_CLOUDFLARE_TOKEN
secret, and server-side prep (create rubykatzen-com user, install the
age private key, cloudflared tunnel). workflow_dispatch-only until
those land.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wkeye.yml
The manifest identity is the server + isolated user + age key it
decrypts on (hawkeye), not the domain/service currently running
there (rubykatzen.com/rybbit) or a repo-grouping level that doesn't
apply to self-hosting flightdeck's own deploys. Matches the existing
convention elsewhere of naming manifests/assets after the server
(mainframe, hawkeye), and drops the redundant projects/rubykatzen/
nesting -- one flat targets/ directory, one file per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the targets/ rename: the age key, release tag, and
asset identify the deploy target (server + isolated user + key), not
the service currently running there. Renamed rubykatzen-com.pub ->
hawkeye.pub, rubykatzen-com-config -> hawkeye-config,
rubykatzen-com.sops.env -> hawkeye.sops.env. The RUBYKATZEN_COM_*
GitHub variable/secret names are untouched -- those describe the
service's own config content, not the target identity, and the
rubykatzen-com OS username/home path on the server is a separate,
correctly-still-rubykatzen-com concept.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
We were the odd one out: deploy-rubykatzen-com.yml already calls
deploy-shared.yml as a real interface, but publish-rubykatzen-com-env.yml
hand-assembled its own "ensure release exists" step around the local
publish-sops-env composite action instead of going through an
equivalent reusable workflow -- because that reusable workflow didn't
exist yet, only the composite action did.
Add publish-sops-env-shared.yml (checkout, resolve release tag --
input or read from the manifest's own release_tag, ensure the release
exists, call publish-sops-env) and rewrite our own
publish-rubykatzen-com-env.yml to just call it, matching the same
consumer interface pattern deploy-rubykatzen-com.yml already uses.
Note: this one needs `secrets: inherit` on the caller, unlike
deploy-shared.yml's explicit named secrets -- the manifest can
reference an arbitrary, per-target set of secret/variable names, so
there's no fixed schema to declare individually.
Pinned to @main for now since this file doesn't exist in any released
tag yet; re-pin to a real version once the next release ships, same
as deploy-rubykatzen-com.yml does for deploy-shared.yml@v0.3.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an interface
The old interface leaked Ansible directly to callers -- extra-vars
was a hand-written JSON blob using flightdeck_* variable names lifted
straight from ansible/deploy.yml. Callers shouldn't need to know this
runs on Ansible at all.
Replace with named inputs in plain deploy vocabulary: app-ref,
env-ref, extra-refs (comma-separated), path, keep-releases,
sops-age-key-file. The workflow builds the ansible -e JSON internally
via jq. No behavior change for existing values, just the surface.
Added defaults for the ones that have a sensible one:
- path: ~/flightdeck (matches every example/consumer so far)
- keep-releases: 5
- sops-age-key-file: ~/.config/sops/age/keys.txt for the connecting
`user`, computed rather than hardcoded since it depends on which
user connects -- can't be a static workflow_call default.
We have no external consumers of this interface yet to break, so
doing this now rather than carrying the leaky version forward.
Also updates our own deploy-rubykatzen-com.yml to the new interface
(drops explicit path/keep-releases/sops-age-key-file entirely since
the new defaults already match what we were passing), and README's
deploy-shared.yml documentation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the earlier targets/keys/release-tag rename: these
workflows deploy a whole target's APPS list, not one specific
service. If hawkeye ever hosts a second app alongside rybbit, it's
the same workflow, same target -- naming it after rubykatzen.com
(the service currently there) would become misleading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One deploy entry point for the whole repo instead of one workflow
file per target. Each target is its own job (currently just
hawkeye); a workflow_dispatch `target` input picks which job(s) run
-- "all" (default) runs every target, a specific name runs just that
job. This is also the pattern we want client repos to copy: a system
that knows exactly which target it just pushed config for can
`gh workflow run deploy.yml -f target=<name>` instead of redeploying
the whole fleet.
No scheduling added yet -- that depends on the idempotent
reconciliation work in #100/#101 landing first, otherwise a cron
trigger would just force-restart everything on every tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fter it
- Rename release-please.yml -> release.yml (name: Release Please ->
Release), matching the file.
- deploy.yml now also fires on workflow_run of Release (in addition
to manual workflow_dispatch). Auto-redeploys hawkeye whenever
Release completes successfully -- including no-op runs where
release-please found nothing to release, since workflow_run
doesn't expose that workflow's own job outputs. Accepted as an MVP
tradeoff: harmless once deploy.sh is idempotent (#100), just an
unnecessary restart until then.
- Fixed a real bug this surfaced: deploy-shared.yml's app-ref input
was reused as both the checkout ref (needs a real git ref: branch,
tag, SHA) and the flightdeck_app_ref bundle to deploy (can be
'latest', a GitHub Releases API concept, not a git ref). Passing
app-ref: latest previously would have broken the checkout step.
Split "Resolve checkout ref" (always derived from workflow_ref,
used only for actions/checkout) from app-ref resolution inside
"Build extra-vars" (input override, defaulting to the checkout ref
if not given). deploy.yml's hawkeye job now passes app-ref: latest
so it always deploys the newest release regardless of which ref
runs the playbook mechanism itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No external consumers yet, so no reason to carry the extra branch:
"if app-ref is empty, reuse whatever ref runs the playbook mechanism"
was solving a DRY concern (avoid writing the version twice) at the
cost of conflating two genuinely different things -- which ref checks
out ansible/deploy.yml vs which release gets deployed. Now that we
need them to diverge (app-ref: latest while pinned to a stable
mechanism version), the implicit default is just a trap. Require
app-ref explicitly instead; deploy.yml already passes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml's upload job was hand-assembled from checkout +
build-bundle + gh release upload steps directly, the same pattern we
already moved away from for publish-sops-env. Extract it into a
generic reusable workflow: checkout a ref, build a zip via the
build-bundle composite action, upload it to an existing release.
release.yml's own upload job now just calls it via a local path
(./.github/workflows/upload-bundle-shared.yml) -- same-repo
self-reference, no ref-pinning concern since it always runs from
whatever commit release.yml itself runs from.
Also generic enough for external use: a consumer repo packaging its
own apps/ directory as an extra bundle for flightdeck_extra_refs can
call it the same way (checkout defaults to the caller's own repo,
same as any reusable workflow without an explicit repository:
override).
Docs: added README sections for both upload-bundle-shared.yml and
publish-sops-env-shared.yml (the latter existed but was never
documented), updated the GitHub Actions intro sentence to list all
three reusable workflows, and fixed a stale release-please.yml
filename left over in the project tree from the earlier rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f upload-apps-shared.yml
upload-bundle-shared.yml took bundle-name/paths as caller inputs, but
in practice there's exactly one caller (this repo's own release.yml)
with fixed, well-known content -- flightdeck.zip is core scripts +
apps/ + README, not something that varies per call. Generic
parameterization here just pushed config a consumer shouldn't need to
know onto the call site. Baked it in; the workflow now only takes
ref/release-tag.
Split the actual generic use case -- a consumer repo bundling its own
apps/ as an extra bundle for flightdeck_extra_refs -- into its own
purpose-built upload-apps-shared.yml (fixed to apps.zip / apps/ only).
Added a comment at release.yml's upload job pointing consumers at it,
so reading our own release flow makes the intended split obvious
rather than inviting a copy-paste of flightdeck-specific config.
Also: switched every local composite-action reference inside these
shared workflows (publish-sops-env-shared.yml, upload-bundle-shared.yml,
upload-apps-shared.yml) from ./.github/actions/x to $/.github/actions/x
-- GitHub's new (July 2026) self-repository syntax. ./ inside a
reusable workflow resolves against the CALLER's repo, which is wrong
here and would have broken the moment an external repo actually called
publish-sops-env-shared.yml or upload-apps-shared.yml (our own usage
so far has been same-repo, so this was latent). $/ always resolves
against the workflow's own defining repo at the exact ref running,
regardless of caller -- matches the pattern rubykatzen/baseline
already uses in lint-shared.yml.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… a separate trigger
publish-hawkeye-env.yml had its own independent trigger (push when
targets/hawkeye.yml or keys/hawkeye.pub changed), decoupled from
whether a release actually happened. Config changes for a target are
conceptually part of the release cycle here, same as the bundle --
fold it into release.yml as another job gated on release_created,
same shape as upload. Delete the standalone workflow; a per-target
manual/path-based publish trigger isn't needed once every target's
env publish just rides along with the release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the publish-hawkeye-env.yml removal in
2b7b16a -- this is the actual job that replaces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Predates the explicit per-target job pattern (targets/hawkeye.yml,
named jobs in release.yml/deploy.yml) this repo now actually uses --
it was built for a dynamic glob-based matrix discovery approach we
never ended up using here, and nothing in this repo calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the action removal in d1472bb.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build-bundle was the odd one out -- publish-sops-env already does
render+encrypt+upload as one action, but build-bundle only built the
zip and left the gh release upload as a separate step in each caller.
Fold upload into build-bundle itself (new release-tag/token inputs),
so upload-bundle-shared.yml and upload-apps-shared.yml both drop to
checkout + one action call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deploy.yml previously fired on every completed "Release" workflow_run,
including runs where release-please created no release - which meant
every ordinary merge to main would redeploy hawkeye and bounce
traefik/rybbit. Move the automatic deploy into release.yml, gated on
release_created like upload/targets/encrypt, and leave deploy.yml with
only the manual workflow_dispatch redeploy path.
"targets" already means deploy destinations (servers). Reusing that
name for the generic config-matrix reader used by both encrypt and
deploy modes was confusing. Rename the action to load-matrix, its
name-selector input/flag from target to name, and the matrix item's
target field to name. Also rename release.yml's encrypt-mode job from
targets to encrypt-configs so it doesn't collide semantically with the
new deploy-targets job.
Rename load-matrix to load-yaml-matrix and strip out its encrypt/deploy
schema knowledge. It now just reads every YAML file in a directory,
merges each file's top-level fields with name/manifest, and builds a
matrix - no mode, no field validation, no defaults.
encrypt-env already re-parses and validates its own manifest from the
file path, so the encrypt/ path loses nothing. The targets/ path had
no other validator, so move what it relied on into the callers:
release.yml and deploy.yml now apply the path/keep-releases/sops-age-
key-file defaults inline and read credentials.secrets/credentials.variables
directly off the raw matrix item instead of the flattened fields
load-matrix used to produce.
This drops schema validation entirely (SSH destination format, ref
format, duplicate hosts, unknown-key typos) - a malformed targets/*.yml
now only surfaces once deploy-shared.yml or ansible/deploy.yml actually
runs against production hosts, not before.
targets/ is a plural noun naming what it holds (deploy targets).
encrypt/ was a singular verb, inconsistent with that pattern and with
its own contents (env asset configs, one per encrypt-env manifest).
Rename to envs/ to match.
envs/ read unnaturally next to targets/ and keys/. sops/ matches the
asset naming convention already used throughout (*.sops.env) and pairs
directly with keys/, which already holds the age recipients used to
encrypt into that directory's assets.
One repo prepares vaults from a pool of secrets/vars; different
consumers (targets) unpack the vault meant for them. Also just a
better English word for "a named container of credentials" than
sops/, which only referenced the encryption mechanism.
encrypt-configs -> encrypt-vaults, matching the deploy-targets job it
sits next to (both <verb>-<directory> now). deploy.yml's targets job
becomes deploy-targets to match release.yml's job of the same purpose,
and its step id follows the matrix convention used everywhere else.
Core apps and third-party extra apps were structurally identical
(an apps/ directory) but packaged asymmetrically: core was baked into
flightdeck.zip, extras went through a separate build-bundle+merge path.
Publish flightdeck's own apps/ as flightdeck-apps.zip via the same
build-bundle action, and merge it through the exact same ansible path
as any other app bundle - removing the special-cased "extract core zip
as the apps base" step entirely.
extra_refs is retired in favor of app_refs, now required and non-empty
throughout (targets/*.yml, deploy-shared.yml's app-refs input, and
ansible's flightdeck_app_refs): a target must explicitly list every
app bundle it wants, including flightdeck's own - there's no more
implicit apps/ riding along with the machinery bundle.
… defaults
build-bundle's paths and bundle-name now default to Flightdeck's own
machinery bundle instead of being required on every call, so the
canonical file list lives in one place (the action) instead of inline
in release.yml.
build-apps-bundle is a new, thin defaults wrapper around build-bundle
(paths: apps, bundle-name: flightdeck-apps.zip) used both for
flightdeck's own apps/ catalog and by any consumer repo publishing its
own app bundle the same way.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves Flightdeck into a self-hosting setup that can publish its own machinery/apps bundles, encrypt env assets from vaults/ manifests, and deploy targets/—including a concrete hawkeye target for running traefik + rybbit for rubykatzen.com.

Changes:

  • Introduces a vaults/ + targets/ configuration model (with hawkeye manifests) and updates docs to match.
  • Adds new composite actions (encrypt-env, load-yaml-matrix, build-apps-bundle) and updates release automation to publish both flightdeck.zip and flightdeck-apps.zip.
  • Updates the Ansible deploy flow to merge required app bundles (flightdeck_app_refs) into the release and expands ~ paths per SSH user.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
vaults/hawkeye.ymlAdds a concrete vault manifest mapping GitHub Secrets/Vars into an encrypted env asset and defining the app list.
targets/hawkeye.ymlAdds a concrete deploy target selecting bundles, env asset ref, SSH destination, and credential names.
keys/hawkeye.pubAdds the age recipient public key used by encrypt-env for hawkeye.
ansible/deploy.ymlRequires flightdeck_app_refs and merges app bundles into releases/<ts>/apps; expands ~ paths per SSH user.
README.mdDocuments the new deploy interface (flightdeck_app_refs) and the vaults/targets model + new actions/workflows.
AGENTS.mdUpdates internal architecture docs to reflect the new bundle/env/target workflow model.
.github/workflows/release.ymlReplaces the Release Please workflow and adds jobs to upload both bundles, encrypt vaults, and deploy targets on release_created.
.github/workflows/release-please.ymlRemoves the old Release Please workflow file.
.github/workflows/deploy.ymlAdds a manual workflow_dispatch redeploy workflow that selects one target or all targets.
.github/workflows/deploy-shared.ymlRedesigns the reusable workflow interface to accept hosts, app-ref, env-ref, and non-empty app-refs.
.github/actions/build-bundle/action.ymlExtends the bundle builder to upload to an existing release and provides defaults for Flightdeck’s machinery bundle.
.github/actions/build-apps-bundle/action.ymlAdds a thin wrapper that publishes an apps/-only bundle (flightdeck-apps.zip).
.github/actions/encrypt-env/action.ymlAdds composite action to render env from vaults/*, encrypt via SOPS age, and upload to an existing release.
.github/actions/encrypt-env/README.mdDocuments usage and the new manifest format (asset, keys, apps, env).
.github/actions/encrypt-env/requirements.txtPins PyYAML for the action implementation.
.github/actions/encrypt-env/scripts/render-env.pyEnforces the new manifest schema, renders apps as APPS=..., and writes outputs for downstream steps.
.github/actions/encrypt-env/tests/test_render_env.pyUpdates/extends unit tests for the new manifest schema and APPS rendering rules.
.github/actions/load-yaml-matrix/action.ymlAdds composite action to load a directory of YAML files into a matrix + count outputs.
.github/actions/load-yaml-matrix/README.mdDocuments load-yaml-matrix behavior and filename/key validation rules.
.github/actions/load-yaml-matrix/requirements.txtPins PyYAML for the action implementation.
.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.pyImplements matrix building with duplicate-key detection and filename validation.
.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.pyAdds unit tests covering discovery, filtering, and validation failures.
.github/actions/publish-sops-env/action.ymlRemoves the superseded publish-sops-env action.
.github/actions/publish-sops-env/README.mdRemoves the superseded publish-sops-env docs.
.github/actions/discover-manifest-matrix/action.ymlRemoves the unused manifest discovery action.
.github/actions/discover-manifest-matrix/README.mdRemoves the unused manifest discovery docs.
.github/actionlint.yamlAdds actionlint ignores for newer job context properties used in deploy-shared.yml.
Suppressed comments (6)

.github/workflows/release.yml:40

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/build-apps-bundle

.github/workflows/release.yml:69

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/encrypt-env

.github/workflows/release.yml:55

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:88

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:97

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

.github/workflows/deploy.yml:27

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: $/.github/actions/build-bundle
count: ${{ steps.matrix.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: $/.github/actions/load-yaml-matrix
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.

2 participants

@ineedjet
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: deploy rybbit for rubykatzen.com through flightdeck itself by ineedjet · Pull Request #102 · rubykatzen/flightdeck · GitHub
Skip to content

feat: deploy rybbit for rubykatzen.com through flightdeck itself - #102

Merged
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit
Aug 20, 2026
Merged

feat: deploy rybbit for rubykatzen.com through flightdeck itself#102
ineedjet merged 35 commits into
mainfrom
feat/rubykatzen-com-rybbit

Conversation

@ineedjet

@ineedjetineedjet commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Flightdeck now uses its own release and deployment tooling to manage Rybbit for rubykatzen.com on the existing hawkeye server.

The final configuration model separates two independent concerns:

  • vaults/ describes encrypted env assets.
  • targets/ describes deployments and explicitly selects a machinery bundle, app bundles, an env asset, and SSH destinations.

The matching hawkeye.yml filenames are a convention only. The actual relationship is the explicit env_ref in the deployment target, so either collection can live in a repository without the other.

Flightdeck's own apps/ catalog is published as its own release asset (flightdeck-apps.zip), merged into a deploy the same way as any other app bundle. There's no special-cased "core" bundle that apps ride along with implicitly — a target must list every app bundle it wants, flightdeck's own included.

Configuration

vaults/hawkeye.yml defines:

  • the hawkeye.sops.env release asset;
  • the hawkeye age recipient;
  • the plain app list (traefik, rybbit), rendered as APPS=traefik,rybbit;
  • mappings from dotenv names to GitHub Secrets or Variables.

targets/hawkeye.yml defines:

  • machinery bundle: rubykatzen/flightdeck@latest;
  • app bundles (app_refs, required, non-empty): rubykatzen/flightdeck@latest;
  • env asset: rubykatzen/flightdeck@latest:hawkeye.sops.env;
  • SSH destination: rubykatzen-com@100.75.50.2;
  • names of the GitHub credentials needed for SSH and optional Tailscale access.

Credential values are never stored in target files.

Automation

  • Added build-bundle, which builds and uploads a bundle to an existing release; paths/bundle-name default to Flightdeck's own machinery bundle (flightdeck.zip) but are fully overridable.
  • Added build-apps-bundle, a thin defaults wrapper around build-bundle (paths: apps, bundle-name: flightdeck-apps.zip) used both for flightdeck's own apps/ catalog and any consumer repo's own app bundle.
  • Replaced publish-sops-env with encrypt-env, which renders a flat config from a vaults/ manifest, encrypts it with SOPS age recipients, and uploads it to an existing release.
  • Added load-yaml-matrix, a schema-agnostic action that reads every YAML file in a caller-given directory into a GitHub Actions matrix (no directory default, no validation — callers own their manifest shape).
  • Removed the unused manifest discovery action and the asset-oriented reusable workflows; release jobs call the composite actions directly.
  • Renamed the Release Please workflow to release.yml. A created release uploads flightdeck.zip and flightdeck-apps.zip, processes every config in vaults/, and deploys every config in targets/ — all gated on release_created, so an ordinary merge to main that doesn't cut a release does nothing.
  • Added deploy.yml for manual redeploys only (workflow_dispatch, select a target or "all"). It is deliberately not triggered automatically — an earlier version fired on every completed Release workflow run regardless of whether a release was actually created, which would have redeployed hawkeye (and bounced traefik/rybbit) on every ordinary merge to main.

Deployment Interface

deploy-shared.yml exposes deployment vocabulary rather than Ansible implementation details:

  • hosts are JSON arrays of user@host SSH destinations;
  • the machinery bundle, env asset, and app bundles use complete owner/repo@tag[:asset] refs; app-refs is a required, non-empty JSON array;
  • path, retention, and the SOPS age key path have defaults and remain overridable;
  • user@host values are converted to an internal Ansible JSON inventory;
  • ~ paths are expanded per SSH user on each target host.

The default deployment path is ~/flightdeck. The default age key path is ~/.config/sops/age/keys.txt, matching the normal Linux SOPS location for the connecting user while still working under Ansible privilege escalation.

External Prerequisites

  • GitHub Secret DEPLOY_SSH_PRIVATE_KEY.
  • GitHub Variable TAILSCALE_OAUTH_CLIENT_ID and Secret TAILSCALE_OAUTH_SECRET when Tailscale is used.
  • All GitHub Secret/Variable names referenced by vaults/hawkeye.yml.
  • The server-side rubykatzen-com user, corresponding SSH access and age private key, Docker, GitHub CLI, SOPS, and the external Cloudflare Tunnel configuration.

Verification

  • 9 load-yaml-matrix unit tests
  • 8 encrypt-env unit tests
  • Actual encrypt and deploy matrix generation, traced against the real vaults/hawkeye.yml and targets/hawkeye.yml
  • Generated inventory parsed by ansible-inventory
  • ansible-playbook --syntax-check ansible/deploy.yml
  • pre-commit run --all-files
  • GitHub Embedder, GitHub config, and lint checks
  • Publish the first real Hawkeye env asset after external prerequisites are configured
  • Run the first real Hawkeye deployment

ineedjetand others added 30 commits August 18, 2026 21:27
First real (non-CI-test) self-consumption of deploy-shared.yml:
flightdeck deploys its own rybbit instance at rybbit.rubykatzen.com,
via an isolated rubykatzen-com user on the existing hawkeye host
(Tailscale IP 100.75.50.2), separate from dupmachine's own services
on that box (own traefik on 8080/8443 to avoid the port conflict,
own age keypair, own env manifest).
- projects/rubykatzen/rubykatzen-com.yml: publish-sops-env manifest
(APPS=traefik,rybbit, Cloudflare DNS challenge cert resolver).
- keys/rubykatzen-com.pub: age recipient for this deployment's
encrypted env. Private key generated locally, not committed --
goes on the server at flightdeck_sops_age_key_file, out of band.
- publish-rubykatzen-com-env.yml: renders + encrypts the manifest,
publishes it to the rubykatzen-com-config release tag.
- deploy-rubykatzen-com.yml: calls deploy-shared.yml@v0.3.0 (pinned,
same as a real external consumer would) against 100.75.50.2.
Blocked on, not part of this diff: DEPLOY_SSH_PRIVATE_KEY secret,
TAILSCALE_OAUTH_CLIENT_ID/SECRET, RUBYKATZEN_COM_CLOUDFLARE_TOKEN
secret, and server-side prep (create rubykatzen-com user, install the
age private key, cloudflared tunnel). workflow_dispatch-only until
those land.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wkeye.yml
The manifest identity is the server + isolated user + age key it
decrypts on (hawkeye), not the domain/service currently running
there (rubykatzen.com/rybbit) or a repo-grouping level that doesn't
apply to self-hosting flightdeck's own deploys. Matches the existing
convention elsewhere of naming manifests/assets after the server
(mainframe, hawkeye), and drops the redundant projects/rubykatzen/
nesting -- one flat targets/ directory, one file per deploy target.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the targets/ rename: the age key, release tag, and
asset identify the deploy target (server + isolated user + key), not
the service currently running there. Renamed rubykatzen-com.pub ->
hawkeye.pub, rubykatzen-com-config -> hawkeye-config,
rubykatzen-com.sops.env -> hawkeye.sops.env. The RUBYKATZEN_COM_*
GitHub variable/secret names are untouched -- those describe the
service's own config content, not the target identity, and the
rubykatzen-com OS username/home path on the server is a separate,
correctly-still-rubykatzen-com concept.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
We were the odd one out: deploy-rubykatzen-com.yml already calls
deploy-shared.yml as a real interface, but publish-rubykatzen-com-env.yml
hand-assembled its own "ensure release exists" step around the local
publish-sops-env composite action instead of going through an
equivalent reusable workflow -- because that reusable workflow didn't
exist yet, only the composite action did.
Add publish-sops-env-shared.yml (checkout, resolve release tag --
input or read from the manifest's own release_tag, ensure the release
exists, call publish-sops-env) and rewrite our own
publish-rubykatzen-com-env.yml to just call it, matching the same
consumer interface pattern deploy-rubykatzen-com.yml already uses.
Note: this one needs `secrets: inherit` on the caller, unlike
deploy-shared.yml's explicit named secrets -- the manifest can
reference an arbitrary, per-target set of secret/variable names, so
there's no fixed schema to declare individually.
Pinned to @main for now since this file doesn't exist in any released
tag yet; re-pin to a real version once the next release ships, same
as deploy-rubykatzen-com.yml does for deploy-shared.yml@v0.3.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…an interface
The old interface leaked Ansible directly to callers -- extra-vars
was a hand-written JSON blob using flightdeck_* variable names lifted
straight from ansible/deploy.yml. Callers shouldn't need to know this
runs on Ansible at all.
Replace with named inputs in plain deploy vocabulary: app-ref,
env-ref, extra-refs (comma-separated), path, keep-releases,
sops-age-key-file. The workflow builds the ansible -e JSON internally
via jq. No behavior change for existing values, just the surface.
Added defaults for the ones that have a sensible one:
- path: ~/flightdeck (matches every example/consumer so far)
- keep-releases: 5
- sops-age-key-file: ~/.config/sops/age/keys.txt for the connecting
`user`, computed rather than hardcoded since it depends on which
user connects -- can't be a static workflow_call default.
We have no external consumers of this interface yet to break, so
doing this now rather than carrying the leaky version forward.
Also updates our own deploy-rubykatzen-com.yml to the new interface
(drops explicit path/keep-releases/sops-age-key-file entirely since
the new defaults already match what we were passing), and README's
deploy-shared.yml documentation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same reasoning as the earlier targets/keys/release-tag rename: these
workflows deploy a whole target's APPS list, not one specific
service. If hawkeye ever hosts a second app alongside rybbit, it's
the same workflow, same target -- naming it after rubykatzen.com
(the service currently there) would become misleading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One deploy entry point for the whole repo instead of one workflow
file per target. Each target is its own job (currently just
hawkeye); a workflow_dispatch `target` input picks which job(s) run
-- "all" (default) runs every target, a specific name runs just that
job. This is also the pattern we want client repos to copy: a system
that knows exactly which target it just pushed config for can
`gh workflow run deploy.yml -f target=<name>` instead of redeploying
the whole fleet.
No scheduling added yet -- that depends on the idempotent
reconciliation work in #100/#101 landing first, otherwise a cron
trigger would just force-restart everything on every tick.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…fter it
- Rename release-please.yml -> release.yml (name: Release Please ->
Release), matching the file.
- deploy.yml now also fires on workflow_run of Release (in addition
to manual workflow_dispatch). Auto-redeploys hawkeye whenever
Release completes successfully -- including no-op runs where
release-please found nothing to release, since workflow_run
doesn't expose that workflow's own job outputs. Accepted as an MVP
tradeoff: harmless once deploy.sh is idempotent (#100), just an
unnecessary restart until then.
- Fixed a real bug this surfaced: deploy-shared.yml's app-ref input
was reused as both the checkout ref (needs a real git ref: branch,
tag, SHA) and the flightdeck_app_ref bundle to deploy (can be
'latest', a GitHub Releases API concept, not a git ref). Passing
app-ref: latest previously would have broken the checkout step.
Split "Resolve checkout ref" (always derived from workflow_ref,
used only for actions/checkout) from app-ref resolution inside
"Build extra-vars" (input override, defaulting to the checkout ref
if not given). deploy.yml's hawkeye job now passes app-ref: latest
so it always deploys the newest release regardless of which ref
runs the playbook mechanism itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No external consumers yet, so no reason to carry the extra branch:
"if app-ref is empty, reuse whatever ref runs the playbook mechanism"
was solving a DRY concern (avoid writing the version twice) at the
cost of conflating two genuinely different things -- which ref checks
out ansible/deploy.yml vs which release gets deployed. Now that we
need them to diverge (app-ref: latest while pinned to a stable
mechanism version), the implicit default is just a trap. Require
app-ref explicitly instead; deploy.yml already passes it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
release.yml's upload job was hand-assembled from checkout +
build-bundle + gh release upload steps directly, the same pattern we
already moved away from for publish-sops-env. Extract it into a
generic reusable workflow: checkout a ref, build a zip via the
build-bundle composite action, upload it to an existing release.
release.yml's own upload job now just calls it via a local path
(./.github/workflows/upload-bundle-shared.yml) -- same-repo
self-reference, no ref-pinning concern since it always runs from
whatever commit release.yml itself runs from.
Also generic enough for external use: a consumer repo packaging its
own apps/ directory as an extra bundle for flightdeck_extra_refs can
call it the same way (checkout defaults to the caller's own repo,
same as any reusable workflow without an explicit repository:
override).
Docs: added README sections for both upload-bundle-shared.yml and
publish-sops-env-shared.yml (the latter existed but was never
documented), updated the GitHub Actions intro sentence to list all
three reusable workflows, and fixed a stale release-please.yml
filename left over in the project tree from the earlier rename.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…f upload-apps-shared.yml
upload-bundle-shared.yml took bundle-name/paths as caller inputs, but
in practice there's exactly one caller (this repo's own release.yml)
with fixed, well-known content -- flightdeck.zip is core scripts +
apps/ + README, not something that varies per call. Generic
parameterization here just pushed config a consumer shouldn't need to
know onto the call site. Baked it in; the workflow now only takes
ref/release-tag.
Split the actual generic use case -- a consumer repo bundling its own
apps/ as an extra bundle for flightdeck_extra_refs -- into its own
purpose-built upload-apps-shared.yml (fixed to apps.zip / apps/ only).
Added a comment at release.yml's upload job pointing consumers at it,
so reading our own release flow makes the intended split obvious
rather than inviting a copy-paste of flightdeck-specific config.
Also: switched every local composite-action reference inside these
shared workflows (publish-sops-env-shared.yml, upload-bundle-shared.yml,
upload-apps-shared.yml) from ./.github/actions/x to $/.github/actions/x
-- GitHub's new (July 2026) self-repository syntax. ./ inside a
reusable workflow resolves against the CALLER's repo, which is wrong
here and would have broken the moment an external repo actually called
publish-sops-env-shared.yml or upload-apps-shared.yml (our own usage
so far has been same-repo, so this was latent). $/ always resolves
against the workflow's own defining repo at the exact ref running,
regardless of caller -- matches the pattern rubykatzen/baseline
already uses in lint-shared.yml.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… a separate trigger
publish-hawkeye-env.yml had its own independent trigger (push when
targets/hawkeye.yml or keys/hawkeye.pub changed), decoupled from
whether a release actually happened. Config changes for a target are
conceptually part of the release cycle here, same as the bundle --
fold it into release.yml as another job gated on release_created,
same shape as upload. Delete the standalone workflow; a per-target
manual/path-based publish trigger isn't needed once every target's
env publish just rides along with the release.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the publish-hawkeye-env.yml removal in
2b7b16a -- this is the actual job that replaces it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Predates the explicit per-target job pattern (targets/hawkeye.yml,
named jobs in release.yml/deploy.yml) this repo now actually uses --
it was built for a dynamic glob-based matrix discovery approach we
never ended up using here, and nothing in this repo calls it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed staging this alongside the action removal in d1472bb.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
build-bundle was the odd one out -- publish-sops-env already does
render+encrypt+upload as one action, but build-bundle only built the
zip and left the gh release upload as a separate step in each caller.
Fold upload into build-bundle itself (new release-tag/token inputs),
so upload-bundle-shared.yml and upload-apps-shared.yml both drop to
checkout + one action call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
deploy.yml previously fired on every completed "Release" workflow_run,
including runs where release-please created no release - which meant
every ordinary merge to main would redeploy hawkeye and bounce
traefik/rybbit. Move the automatic deploy into release.yml, gated on
release_created like upload/targets/encrypt, and leave deploy.yml with
only the manual workflow_dispatch redeploy path.
"targets" already means deploy destinations (servers). Reusing that
name for the generic config-matrix reader used by both encrypt and
deploy modes was confusing. Rename the action to load-matrix, its
name-selector input/flag from target to name, and the matrix item's
target field to name. Also rename release.yml's encrypt-mode job from
targets to encrypt-configs so it doesn't collide semantically with the
new deploy-targets job.
Rename load-matrix to load-yaml-matrix and strip out its encrypt/deploy
schema knowledge. It now just reads every YAML file in a directory,
merges each file's top-level fields with name/manifest, and builds a
matrix - no mode, no field validation, no defaults.
encrypt-env already re-parses and validates its own manifest from the
file path, so the encrypt/ path loses nothing. The targets/ path had
no other validator, so move what it relied on into the callers:
release.yml and deploy.yml now apply the path/keep-releases/sops-age-
key-file defaults inline and read credentials.secrets/credentials.variables
directly off the raw matrix item instead of the flattened fields
load-matrix used to produce.
This drops schema validation entirely (SSH destination format, ref
format, duplicate hosts, unknown-key typos) - a malformed targets/*.yml
now only surfaces once deploy-shared.yml or ansible/deploy.yml actually
runs against production hosts, not before.
targets/ is a plural noun naming what it holds (deploy targets).
encrypt/ was a singular verb, inconsistent with that pattern and with
its own contents (env asset configs, one per encrypt-env manifest).
Rename to envs/ to match.
envs/ read unnaturally next to targets/ and keys/. sops/ matches the
asset naming convention already used throughout (*.sops.env) and pairs
directly with keys/, which already holds the age recipients used to
encrypt into that directory's assets.
One repo prepares vaults from a pool of secrets/vars; different
consumers (targets) unpack the vault meant for them. Also just a
better English word for "a named container of credentials" than
sops/, which only referenced the encryption mechanism.
encrypt-configs -> encrypt-vaults, matching the deploy-targets job it
sits next to (both <verb>-<directory> now). deploy.yml's targets job
becomes deploy-targets to match release.yml's job of the same purpose,
and its step id follows the matrix convention used everywhere else.
Core apps and third-party extra apps were structurally identical
(an apps/ directory) but packaged asymmetrically: core was baked into
flightdeck.zip, extras went through a separate build-bundle+merge path.
Publish flightdeck's own apps/ as flightdeck-apps.zip via the same
build-bundle action, and merge it through the exact same ansible path
as any other app bundle - removing the special-cased "extract core zip
as the apps base" step entirely.
extra_refs is retired in favor of app_refs, now required and non-empty
throughout (targets/*.yml, deploy-shared.yml's app-refs input, and
ansible's flightdeck_app_refs): a target must explicitly list every
app bundle it wants, including flightdeck's own - there's no more
implicit apps/ riding along with the machinery bundle.
… defaults
build-bundle's paths and bundle-name now default to Flightdeck's own
machinery bundle instead of being required on every call, so the
canonical file list lives in one place (the action) instead of inline
in release.yml.
build-apps-bundle is a new, thin defaults wrapper around build-bundle
(paths: apps, bundle-name: flightdeck-apps.zip) used both for
flightdeck's own apps/ catalog and by any consumer repo publishing its
own app bundle the same way.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR evolves Flightdeck into a self-hosting setup that can publish its own machinery/apps bundles, encrypt env assets from vaults/ manifests, and deploy targets/—including a concrete hawkeye target for running traefik + rybbit for rubykatzen.com.

Changes:

  • Introduces a vaults/ + targets/ configuration model (with hawkeye manifests) and updates docs to match.
  • Adds new composite actions (encrypt-env, load-yaml-matrix, build-apps-bundle) and updates release automation to publish both flightdeck.zip and flightdeck-apps.zip.
  • Updates the Ansible deploy flow to merge required app bundles (flightdeck_app_refs) into the release and expands ~ paths per SSH user.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
vaults/hawkeye.ymlAdds a concrete vault manifest mapping GitHub Secrets/Vars into an encrypted env asset and defining the app list.
targets/hawkeye.ymlAdds a concrete deploy target selecting bundles, env asset ref, SSH destination, and credential names.
keys/hawkeye.pubAdds the age recipient public key used by encrypt-env for hawkeye.
ansible/deploy.ymlRequires flightdeck_app_refs and merges app bundles into releases/<ts>/apps; expands ~ paths per SSH user.
README.mdDocuments the new deploy interface (flightdeck_app_refs) and the vaults/targets model + new actions/workflows.
AGENTS.mdUpdates internal architecture docs to reflect the new bundle/env/target workflow model.
.github/workflows/release.ymlReplaces the Release Please workflow and adds jobs to upload both bundles, encrypt vaults, and deploy targets on release_created.
.github/workflows/release-please.ymlRemoves the old Release Please workflow file.
.github/workflows/deploy.ymlAdds a manual workflow_dispatch redeploy workflow that selects one target or all targets.
.github/workflows/deploy-shared.ymlRedesigns the reusable workflow interface to accept hosts, app-ref, env-ref, and non-empty app-refs.
.github/actions/build-bundle/action.ymlExtends the bundle builder to upload to an existing release and provides defaults for Flightdeck’s machinery bundle.
.github/actions/build-apps-bundle/action.ymlAdds a thin wrapper that publishes an apps/-only bundle (flightdeck-apps.zip).
.github/actions/encrypt-env/action.ymlAdds composite action to render env from vaults/*, encrypt via SOPS age, and upload to an existing release.
.github/actions/encrypt-env/README.mdDocuments usage and the new manifest format (asset, keys, apps, env).
.github/actions/encrypt-env/requirements.txtPins PyYAML for the action implementation.
.github/actions/encrypt-env/scripts/render-env.pyEnforces the new manifest schema, renders apps as APPS=..., and writes outputs for downstream steps.
.github/actions/encrypt-env/tests/test_render_env.pyUpdates/extends unit tests for the new manifest schema and APPS rendering rules.
.github/actions/load-yaml-matrix/action.ymlAdds composite action to load a directory of YAML files into a matrix + count outputs.
.github/actions/load-yaml-matrix/README.mdDocuments load-yaml-matrix behavior and filename/key validation rules.
.github/actions/load-yaml-matrix/requirements.txtPins PyYAML for the action implementation.
.github/actions/load-yaml-matrix/scripts/load-yaml-matrix.pyImplements matrix building with duplicate-key detection and filename validation.
.github/actions/load-yaml-matrix/tests/test_load_yaml_matrix.pyAdds unit tests covering discovery, filtering, and validation failures.
.github/actions/publish-sops-env/action.ymlRemoves the superseded publish-sops-env action.
.github/actions/publish-sops-env/README.mdRemoves the superseded publish-sops-env docs.
.github/actions/discover-manifest-matrix/action.ymlRemoves the unused manifest discovery action.
.github/actions/discover-manifest-matrix/README.mdRemoves the unused manifest discovery docs.
.github/actionlint.yamlAdds actionlint ignores for newer job context properties used in deploy-shared.yml.
Suppressed comments (6)

.github/workflows/release.yml:40

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/build-apps-bundle

.github/workflows/release.yml:69

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/encrypt-env

.github/workflows/release.yml:55

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:88

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local action reference in GitHub Actions and will fail to load this action. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 - uses: $/.github/actions/load-yaml-matrix

.github/workflows/release.yml:97

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

.github/workflows/deploy.yml:27

  • 🥽 [rubykatzen/flightdeck]: uses: $/.github/... is not a valid local reusable-workflow reference in GitHub Actions and will fail to call the workflow. Use a relative path starting with ./ instead.

🛠️ 🧪 📦

 uses: $/.github/workflows/deploy-shared.yml

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

- uses: actions/checkout@v7
with:
ref: ${{ needs.release.outputs.tag_name }}
- uses: $/.github/actions/build-bundle
count: ${{ steps.matrix.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: $/.github/actions/load-yaml-matrix
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.

2 participants

@ineedjet