Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f14cbd
feat: deploy rybbit for rubykatzen.com through flightdeck itself
ineedjet Aug 18, 2026
7cbcf65
refactor: rename projects/rubykatzen/rubykatzen-com.yml to targets/ha…
ineedjet Aug 18, 2026
0365987
refactor: name key, release tag, and asset after the target (hawkeye)
ineedjet Aug 18, 2026
db021de
feat: add publish-sops-env-shared.yml, use it as our own consumer
ineedjet Aug 18, 2026
20d9768
refactor!: replace deploy-shared.yml's raw extra-vars JSON with a cle…
ineedjet Aug 18, 2026
6f970f6
refactor: rename consumer workflows to target-named (hawkeye)
ineedjet Aug 18, 2026
2e3f001
feat: rename deploy-hawkeye.yml to deploy.yml, add a target selector
ineedjet Aug 18, 2026
241081d
feat: rename release-please.yml to release.yml, auto-trigger deploy a…
ineedjet Aug 18, 2026
3f44408
refactor!: make app-ref required, drop the checkout-ref fallback default
ineedjet Aug 18, 2026
8f3286e
feat: extract upload-bundle-shared.yml, use it from release.yml
ineedjet Aug 18, 2026
1e192a5
refactor!: bake bundle config into upload-bundle-shared.yml, split of…
ineedjet Aug 18, 2026
2b7b16a
refactor: publish hawkeye's encrypted env as part of the release, not…
ineedjet Aug 18, 2026
1090efd
feat: add hawkeye-env job to release.yml
ineedjet Aug 18, 2026
d1472bb
chore: remove unused discover-manifest-matrix action
ineedjet Aug 18, 2026
fe108e7
docs: remove discover-manifest-matrix from README
ineedjet Aug 18, 2026
74e4d02
refactor: merge build-bundle and upload into one composite action
ineedjet Aug 18, 2026
e7aed57
fix: make shared deployment workflows runnable
ineedjet Aug 18, 2026
7558bd4
refactor!: replace asset workflows with actions
ineedjet Aug 18, 2026
14f7e14
refactor: publish env to the created release
ineedjet Aug 18, 2026
a18ec62
feat: drive encryption and deploys from targets
ineedjet Aug 18, 2026
a1db561
feat: define target apps in encryption config
ineedjet Aug 19, 2026
ae0caa1
refactor: use default target paths
ineedjet Aug 19, 2026
c57535c
refactor: hide ansible inventory from deploy interface
ineedjet Aug 19, 2026
9463898
refactor: describe deploy hosts as ssh destinations
ineedjet Aug 19, 2026
f1100c2
refactor: separate encryption configs from deploy targets
ineedjet Aug 19, 2026
2c1f604
fix: gate automatic deploy on an actual release, not any Release run
ineedjet Aug 19, 2026
c6d01db
refactor: rename load-targets to load-matrix
ineedjet Aug 19, 2026
b6aad6a
refactor: make load-matrix a dumb directory-to-matrix reader
ineedjet Aug 19, 2026
18a13a0
refactor: rename encrypt/ to envs/
ineedjet Aug 19, 2026
9a419a3
refactor: rename envs/ to sops/
ineedjet Aug 19, 2026
9ba35e3
refactor: rename sops/ to vaults/
ineedjet Aug 19, 2026
ae6f6a1
refactor: homogenize matrix job naming across release.yml and deploy.yml
ineedjet Aug 19, 2026
285b1e1
refactor: publish flightdeck's apps/ as its own release asset
ineedjet Aug 19, 2026
5976f34
refactor: split build-bundle, push paths/bundle-name into overridable…
ineedjet Aug 19, 2026
e43cfd7
docs: mark path/sops_age_key_file as optional in the targets example
ineedjet Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/deploy-shared.yml:
# actionlint 1.7.12 predates these GitHub job context properties.
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
31 changes: 31 additions & 0 deletions .github/actions/build-apps-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
name: Build and upload apps bundle
description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: false
default: apps
bundle-name:
description: Bundle archive filename.
required: false
default: flightdeck-apps.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
value: ${{ steps.build.outputs.bundle-path }}
runs:
using: composite
steps:
- uses: ./.github/actions/build-bundle
id: build
with:
paths: ${{ inputs.paths }}
bundle-name: ${{ inputs.bundle-name }}
release-tag: ${{ inputs.release-tag }}
token: ${{ inputs.token }}
33 changes: 29 additions & 4 deletions .github/actions/build-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
name: Build app bundle
description: Build and validate a zip bundle from specified paths.
name: Build and upload bundle
description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: true
required: false
default: |
ansible.cfg
.env.example
backup.sh
deploy.sh
down.sh
generate-env.sh
lib.sh
logs.sh
restart.sh
up.sh
README.md
bundle-name:
description: Bundle archive filename.
required: false
default: bundle.zip
default: flightdeck.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
Expand DownExpand Up@@ -40,3 +58,10 @@ runs:
exit 1
fi
echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT"
- name: Upload bundle
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }}
run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber
39 changes: 0 additions & 39 deletions .github/actions/discover-manifest-matrix/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions .github/actions/discover-manifest-matrix/action.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
# encrypt-env

Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release.

The release must exist before this action runs.

## Usage

```yaml
- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main
with:
manifest: vaults/mainframe.yml # required
keys-directory: keys # default: keys
release-tag: ${{ needs.release.outputs.tag }} # required, must already exist
release-repo: "" # default: current repository
token: ${{ secrets.GITHUB_TOKEN }} # required
env:
GITHUB_SECRETS_JSON: ${{ toJson(secrets) }}
GITHUB_VARS_JSON: ${{ toJson(vars) }}
```

The calling job requires:

```yaml
permissions:
contents: write
```

## Manifest

```yaml
asset: mainframe.sops.env
keys:
- mainframe
apps:
- traefik
- rybbit
env:
APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name
APPS_TIMEZONE: APPS_TIMEZONE
```

The action renders `apps` as the comma-separated `APPS` dotenv value. For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
108 changes: 108 additions & 0 deletions .github/actions/encrypt-env/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
name: Encrypt env
description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release.
inputs:
manifest:
description: Path to the YAML env manifest.
required: true
release-repo:
description: GitHub repository containing the release. Defaults to the current repository.
required: false
default: ""
release-tag:
description: Existing release tag to publish the asset to.
required: true
keys-directory:
description: Directory containing public age recipients named <key>.pub.
required: false
default: keys
token:
description: Token with permission to write releases.
required: true
outputs:
release-repo:
description: GitHub repository used for publication.
value: ${{ steps.publish.outputs.release_repo }}
tag:
description: Release tag used for publication.
value: ${{ steps.publish.outputs.tag }}
ref:
description: Release asset ref in owner/repo@tag:asset format.
value: ${{ steps.publish.outputs.ref }}
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
env:
SOPS_VERSION: "3.13.1"
run: |
python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt"
mkdir -p "$RUNNER_TEMP/encrypt-env/bin"
curl --fail --location --silent --show-error \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
--output "$RUNNER_TEMP/encrypt-env/bin/sops"
chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops"
echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH"
- name: Render env
id: render
shell: bash
env:
MANIFEST: ${{ inputs.manifest }}
run: |
mkdir -p "$RUNNER_TEMP/encrypt-env"
python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \
--manifest "$MANIFEST" \
--output "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Resolve release target
id: target
shell: bash
env:
INPUT_RELEASE_REPO: ${{ inputs.release-repo }}
DEFAULT_RELEASE_REPO: ${{ github.repository }}
ASSET_NAME: ${{ steps.render.outputs.asset }}
run: |
release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}"
echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Encrypt env
shell: bash
env:
KEYS: ${{ steps.render.outputs.keys }}
KEYS_DIRECTORY: ${{ inputs.keys-directory }}
run: |
recipients=""
IFS=',' read -ra names <<< "$KEYS"
for name in "${names[@]}"; do
recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")"
if [[ ! "$recipient" =~ ^age1 ]]; then
echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2
exit 1
fi
recipients="${recipients:+$recipients,}$recipient"
done
SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \
--input-type dotenv \
--output-type dotenv \
"$RUNNER_TEMP/encrypt-env/plain.env" \
> "$RUNNER_TEMP/encrypt-env/.sops.env"
rm "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Upload encrypted env
id: publish
shell: bash
env:
RELEASE_REPO: ${{ steps.target.outputs.release_repo }}
TAG: ${{ inputs.release-tag }}
ASSET_NAME: ${{ steps.target.outputs.asset_name }}
GH_TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME"
cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path"
gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber
echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Cleanup
if: always()
shell: bash
run: rm -rf "$RUNNER_TEMP/encrypt-env"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,8 @@
ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$")
KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")
APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")


class ManifestError(Exception):
Expand DownExpand Up@@ -59,32 +58,31 @@ def load_manifest(path):
raise ManifestError(f"{path} is not valid YAML: {exc}") from exc
if not isinstance(manifest, dict):
raise ManifestError(f"{path} must contain a YAML mapping")
if "package" in manifest:
raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration")
release_repo = manifest.get("release_repo")
release_tag = manifest.get("release_tag")
release_asset = manifest.get("release_asset", f"{path.stem}.sops.env")
unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"})
if unknown:
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
asset = manifest.get("asset")
keys = manifest.get("keys")
apps = manifest.get("apps")
env = manifest.get("env")
if "raw_env" in manifest:
raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax")
if release_repo is not None and (
not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo)
):
raise ManifestError("release_repo must be in owner/repo format")
if release_tag is not None and (
not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag)
):
raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens")
if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset):
raise ManifestError("release_asset must be named like server.sops.env")
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
raise ManifestError("asset must be named like server.sops.env")
if not isinstance(keys, list) or not keys:
raise ManifestError("keys must be a non-empty list")
if not isinstance(apps, list) or not apps:
raise ManifestError("apps must be a non-empty list")
if not isinstance(env, dict) or not env:
raise ManifestError("env must be a non-empty mapping")
for key in keys:
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
raise ManifestError(f"invalid key name: {key!r}")
for app in apps:
if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app):
raise ManifestError(f"invalid app name: {app!r}")
if len(apps) != len(set(apps)):
raise ManifestError("apps contains duplicate app names")
if "APPS" in env:
raise ManifestError("APPS must be configured through apps")
for output_name, source_name in env.items():
if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name):
raise ManifestError(f"invalid output env name: {output_name!r}")
Expand DownExpand Up@@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables):


def render_env(manifest, secrets, variables):
lines = []
lines = [f"APPS={','.join(manifest['apps'])}"]
missing = []
for output_name, source_name in manifest["env"].items():
value = resolve_value(source_name, secrets, variables)
Expand DownExpand Up@@ -148,9 +146,7 @@ def main(argv=None):
args.output.chmod(0o600)
write_github_outputs(
{
"release_repo": manifest.get("release_repo", ""),
"release_tag": manifest.get("release_tag", ""),
"release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"),
"asset": manifest["asset"],
"keys": ",".join(manifest["keys"]),
}
)
Expand Down
Loading
, '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" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f14cbd
feat: deploy rybbit for rubykatzen.com through flightdeck itself
ineedjet Aug 18, 2026
7cbcf65
refactor: rename projects/rubykatzen/rubykatzen-com.yml to targets/ha…
ineedjet Aug 18, 2026
0365987
refactor: name key, release tag, and asset after the target (hawkeye)
ineedjet Aug 18, 2026
db021de
feat: add publish-sops-env-shared.yml, use it as our own consumer
ineedjet Aug 18, 2026
20d9768
refactor!: replace deploy-shared.yml's raw extra-vars JSON with a cle…
ineedjet Aug 18, 2026
6f970f6
refactor: rename consumer workflows to target-named (hawkeye)
ineedjet Aug 18, 2026
2e3f001
feat: rename deploy-hawkeye.yml to deploy.yml, add a target selector
ineedjet Aug 18, 2026
241081d
feat: rename release-please.yml to release.yml, auto-trigger deploy a…
ineedjet Aug 18, 2026
3f44408
refactor!: make app-ref required, drop the checkout-ref fallback default
ineedjet Aug 18, 2026
8f3286e
feat: extract upload-bundle-shared.yml, use it from release.yml
ineedjet Aug 18, 2026
1e192a5
refactor!: bake bundle config into upload-bundle-shared.yml, split of…
ineedjet Aug 18, 2026
2b7b16a
refactor: publish hawkeye's encrypted env as part of the release, not…
ineedjet Aug 18, 2026
1090efd
feat: add hawkeye-env job to release.yml
ineedjet Aug 18, 2026
d1472bb
chore: remove unused discover-manifest-matrix action
ineedjet Aug 18, 2026
fe108e7
docs: remove discover-manifest-matrix from README
ineedjet Aug 18, 2026
74e4d02
refactor: merge build-bundle and upload into one composite action
ineedjet Aug 18, 2026
e7aed57
fix: make shared deployment workflows runnable
ineedjet Aug 18, 2026
7558bd4
refactor!: replace asset workflows with actions
ineedjet Aug 18, 2026
14f7e14
refactor: publish env to the created release
ineedjet Aug 18, 2026
a18ec62
feat: drive encryption and deploys from targets
ineedjet Aug 18, 2026
a1db561
feat: define target apps in encryption config
ineedjet Aug 19, 2026
ae0caa1
refactor: use default target paths
ineedjet Aug 19, 2026
c57535c
refactor: hide ansible inventory from deploy interface
ineedjet Aug 19, 2026
9463898
refactor: describe deploy hosts as ssh destinations
ineedjet Aug 19, 2026
f1100c2
refactor: separate encryption configs from deploy targets
ineedjet Aug 19, 2026
2c1f604
fix: gate automatic deploy on an actual release, not any Release run
ineedjet Aug 19, 2026
c6d01db
refactor: rename load-targets to load-matrix
ineedjet Aug 19, 2026
b6aad6a
refactor: make load-matrix a dumb directory-to-matrix reader
ineedjet Aug 19, 2026
18a13a0
refactor: rename encrypt/ to envs/
ineedjet Aug 19, 2026
9a419a3
refactor: rename envs/ to sops/
ineedjet Aug 19, 2026
9ba35e3
refactor: rename sops/ to vaults/
ineedjet Aug 19, 2026
ae6f6a1
refactor: homogenize matrix job naming across release.yml and deploy.yml
ineedjet Aug 19, 2026
285b1e1
refactor: publish flightdeck's apps/ as its own release asset
ineedjet Aug 19, 2026
5976f34
refactor: split build-bundle, push paths/bundle-name into overridable…
ineedjet Aug 19, 2026
e43cfd7
docs: mark path/sops_age_key_file as optional in the targets example
ineedjet Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/deploy-shared.yml:
# actionlint 1.7.12 predates these GitHub job context properties.
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
31 changes: 31 additions & 0 deletions .github/actions/build-apps-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
name: Build and upload apps bundle
description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: false
default: apps
bundle-name:
description: Bundle archive filename.
required: false
default: flightdeck-apps.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
value: ${{ steps.build.outputs.bundle-path }}
runs:
using: composite
steps:
- uses: ./.github/actions/build-bundle
id: build
with:
paths: ${{ inputs.paths }}
bundle-name: ${{ inputs.bundle-name }}
release-tag: ${{ inputs.release-tag }}
token: ${{ inputs.token }}
33 changes: 29 additions & 4 deletions .github/actions/build-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
name: Build app bundle
description: Build and validate a zip bundle from specified paths.
name: Build and upload bundle
description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: true
required: false
default: |
ansible.cfg
.env.example
backup.sh
deploy.sh
down.sh
generate-env.sh
lib.sh
logs.sh
restart.sh
up.sh
README.md
bundle-name:
description: Bundle archive filename.
required: false
default: bundle.zip
default: flightdeck.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
Expand DownExpand Up@@ -40,3 +58,10 @@ runs:
exit 1
fi
echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT"
- name: Upload bundle
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }}
run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber
39 changes: 0 additions & 39 deletions .github/actions/discover-manifest-matrix/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions .github/actions/discover-manifest-matrix/action.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
# encrypt-env

Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release.

The release must exist before this action runs.

## Usage

```yaml
- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main
with:
manifest: vaults/mainframe.yml # required
keys-directory: keys # default: keys
release-tag: ${{ needs.release.outputs.tag }} # required, must already exist
release-repo: "" # default: current repository
token: ${{ secrets.GITHUB_TOKEN }} # required
env:
GITHUB_SECRETS_JSON: ${{ toJson(secrets) }}
GITHUB_VARS_JSON: ${{ toJson(vars) }}
```

The calling job requires:

```yaml
permissions:
contents: write
```

## Manifest

```yaml
asset: mainframe.sops.env
keys:
- mainframe
apps:
- traefik
- rybbit
env:
APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name
APPS_TIMEZONE: APPS_TIMEZONE
```

The action renders `apps` as the comma-separated `APPS` dotenv value. For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
108 changes: 108 additions & 0 deletions .github/actions/encrypt-env/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
name: Encrypt env
description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release.
inputs:
manifest:
description: Path to the YAML env manifest.
required: true
release-repo:
description: GitHub repository containing the release. Defaults to the current repository.
required: false
default: ""
release-tag:
description: Existing release tag to publish the asset to.
required: true
keys-directory:
description: Directory containing public age recipients named <key>.pub.
required: false
default: keys
token:
description: Token with permission to write releases.
required: true
outputs:
release-repo:
description: GitHub repository used for publication.
value: ${{ steps.publish.outputs.release_repo }}
tag:
description: Release tag used for publication.
value: ${{ steps.publish.outputs.tag }}
ref:
description: Release asset ref in owner/repo@tag:asset format.
value: ${{ steps.publish.outputs.ref }}
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
env:
SOPS_VERSION: "3.13.1"
run: |
python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt"
mkdir -p "$RUNNER_TEMP/encrypt-env/bin"
curl --fail --location --silent --show-error \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
--output "$RUNNER_TEMP/encrypt-env/bin/sops"
chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops"
echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH"
- name: Render env
id: render
shell: bash
env:
MANIFEST: ${{ inputs.manifest }}
run: |
mkdir -p "$RUNNER_TEMP/encrypt-env"
python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \
--manifest "$MANIFEST" \
--output "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Resolve release target
id: target
shell: bash
env:
INPUT_RELEASE_REPO: ${{ inputs.release-repo }}
DEFAULT_RELEASE_REPO: ${{ github.repository }}
ASSET_NAME: ${{ steps.render.outputs.asset }}
run: |
release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}"
echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Encrypt env
shell: bash
env:
KEYS: ${{ steps.render.outputs.keys }}
KEYS_DIRECTORY: ${{ inputs.keys-directory }}
run: |
recipients=""
IFS=',' read -ra names <<< "$KEYS"
for name in "${names[@]}"; do
recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")"
if [[ ! "$recipient" =~ ^age1 ]]; then
echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2
exit 1
fi
recipients="${recipients:+$recipients,}$recipient"
done
SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \
--input-type dotenv \
--output-type dotenv \
"$RUNNER_TEMP/encrypt-env/plain.env" \
> "$RUNNER_TEMP/encrypt-env/.sops.env"
rm "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Upload encrypted env
id: publish
shell: bash
env:
RELEASE_REPO: ${{ steps.target.outputs.release_repo }}
TAG: ${{ inputs.release-tag }}
ASSET_NAME: ${{ steps.target.outputs.asset_name }}
GH_TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME"
cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path"
gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber
echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Cleanup
if: always()
shell: bash
run: rm -rf "$RUNNER_TEMP/encrypt-env"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,8 @@
ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$")
KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")
APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")


class ManifestError(Exception):
Expand DownExpand Up@@ -59,32 +58,31 @@ def load_manifest(path):
raise ManifestError(f"{path} is not valid YAML: {exc}") from exc
if not isinstance(manifest, dict):
raise ManifestError(f"{path} must contain a YAML mapping")
if "package" in manifest:
raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration")
release_repo = manifest.get("release_repo")
release_tag = manifest.get("release_tag")
release_asset = manifest.get("release_asset", f"{path.stem}.sops.env")
unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"})
if unknown:
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
asset = manifest.get("asset")
keys = manifest.get("keys")
apps = manifest.get("apps")
env = manifest.get("env")
if "raw_env" in manifest:
raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax")
if release_repo is not None and (
not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo)
):
raise ManifestError("release_repo must be in owner/repo format")
if release_tag is not None and (
not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag)
):
raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens")
if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset):
raise ManifestError("release_asset must be named like server.sops.env")
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
raise ManifestError("asset must be named like server.sops.env")
if not isinstance(keys, list) or not keys:
raise ManifestError("keys must be a non-empty list")
if not isinstance(apps, list) or not apps:
raise ManifestError("apps must be a non-empty list")
if not isinstance(env, dict) or not env:
raise ManifestError("env must be a non-empty mapping")
for key in keys:
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
raise ManifestError(f"invalid key name: {key!r}")
for app in apps:
if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app):
raise ManifestError(f"invalid app name: {app!r}")
if len(apps) != len(set(apps)):
raise ManifestError("apps contains duplicate app names")
if "APPS" in env:
raise ManifestError("APPS must be configured through apps")
for output_name, source_name in env.items():
if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name):
raise ManifestError(f"invalid output env name: {output_name!r}")
Expand DownExpand Up@@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables):


def render_env(manifest, secrets, variables):
lines = []
lines = [f"APPS={','.join(manifest['apps'])}"]
missing = []
for output_name, source_name in manifest["env"].items():
value = resolve_value(source_name, secrets, variables)
Expand DownExpand Up@@ -148,9 +146,7 @@ def main(argv=None):
args.output.chmod(0o600)
write_github_outputs(
{
"release_repo": manifest.get("release_repo", ""),
"release_tag": manifest.get("release_tag", ""),
"release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"),
"asset": manifest["asset"],
"keys": ",".join(manifest["keys"]),
}
)
Expand Down
Loading
, '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('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f14cbd
feat: deploy rybbit for rubykatzen.com through flightdeck itself
ineedjet Aug 18, 2026
7cbcf65
refactor: rename projects/rubykatzen/rubykatzen-com.yml to targets/ha…
ineedjet Aug 18, 2026
0365987
refactor: name key, release tag, and asset after the target (hawkeye)
ineedjet Aug 18, 2026
db021de
feat: add publish-sops-env-shared.yml, use it as our own consumer
ineedjet Aug 18, 2026
20d9768
refactor!: replace deploy-shared.yml's raw extra-vars JSON with a cle…
ineedjet Aug 18, 2026
6f970f6
refactor: rename consumer workflows to target-named (hawkeye)
ineedjet Aug 18, 2026
2e3f001
feat: rename deploy-hawkeye.yml to deploy.yml, add a target selector
ineedjet Aug 18, 2026
241081d
feat: rename release-please.yml to release.yml, auto-trigger deploy a…
ineedjet Aug 18, 2026
3f44408
refactor!: make app-ref required, drop the checkout-ref fallback default
ineedjet Aug 18, 2026
8f3286e
feat: extract upload-bundle-shared.yml, use it from release.yml
ineedjet Aug 18, 2026
1e192a5
refactor!: bake bundle config into upload-bundle-shared.yml, split of…
ineedjet Aug 18, 2026
2b7b16a
refactor: publish hawkeye's encrypted env as part of the release, not…
ineedjet Aug 18, 2026
1090efd
feat: add hawkeye-env job to release.yml
ineedjet Aug 18, 2026
d1472bb
chore: remove unused discover-manifest-matrix action
ineedjet Aug 18, 2026
fe108e7
docs: remove discover-manifest-matrix from README
ineedjet Aug 18, 2026
74e4d02
refactor: merge build-bundle and upload into one composite action
ineedjet Aug 18, 2026
e7aed57
fix: make shared deployment workflows runnable
ineedjet Aug 18, 2026
7558bd4
refactor!: replace asset workflows with actions
ineedjet Aug 18, 2026
14f7e14
refactor: publish env to the created release
ineedjet Aug 18, 2026
a18ec62
feat: drive encryption and deploys from targets
ineedjet Aug 18, 2026
a1db561
feat: define target apps in encryption config
ineedjet Aug 19, 2026
ae0caa1
refactor: use default target paths
ineedjet Aug 19, 2026
c57535c
refactor: hide ansible inventory from deploy interface
ineedjet Aug 19, 2026
9463898
refactor: describe deploy hosts as ssh destinations
ineedjet Aug 19, 2026
f1100c2
refactor: separate encryption configs from deploy targets
ineedjet Aug 19, 2026
2c1f604
fix: gate automatic deploy on an actual release, not any Release run
ineedjet Aug 19, 2026
c6d01db
refactor: rename load-targets to load-matrix
ineedjet Aug 19, 2026
b6aad6a
refactor: make load-matrix a dumb directory-to-matrix reader
ineedjet Aug 19, 2026
18a13a0
refactor: rename encrypt/ to envs/
ineedjet Aug 19, 2026
9a419a3
refactor: rename envs/ to sops/
ineedjet Aug 19, 2026
9ba35e3
refactor: rename sops/ to vaults/
ineedjet Aug 19, 2026
ae6f6a1
refactor: homogenize matrix job naming across release.yml and deploy.yml
ineedjet Aug 19, 2026
285b1e1
refactor: publish flightdeck's apps/ as its own release asset
ineedjet Aug 19, 2026
5976f34
refactor: split build-bundle, push paths/bundle-name into overridable…
ineedjet Aug 19, 2026
e43cfd7
docs: mark path/sops_age_key_file as optional in the targets example
ineedjet Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/deploy-shared.yml:
# actionlint 1.7.12 predates these GitHub job context properties.
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
31 changes: 31 additions & 0 deletions .github/actions/build-apps-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
name: Build and upload apps bundle
description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: false
default: apps
bundle-name:
description: Bundle archive filename.
required: false
default: flightdeck-apps.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
value: ${{ steps.build.outputs.bundle-path }}
runs:
using: composite
steps:
- uses: ./.github/actions/build-bundle
id: build
with:
paths: ${{ inputs.paths }}
bundle-name: ${{ inputs.bundle-name }}
release-tag: ${{ inputs.release-tag }}
token: ${{ inputs.token }}
33 changes: 29 additions & 4 deletions .github/actions/build-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
name: Build app bundle
description: Build and validate a zip bundle from specified paths.
name: Build and upload bundle
description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: true
required: false
default: |
ansible.cfg
.env.example
backup.sh
deploy.sh
down.sh
generate-env.sh
lib.sh
logs.sh
restart.sh
up.sh
README.md
bundle-name:
description: Bundle archive filename.
required: false
default: bundle.zip
default: flightdeck.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
Expand DownExpand Up@@ -40,3 +58,10 @@ runs:
exit 1
fi
echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT"
- name: Upload bundle
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }}
run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber
39 changes: 0 additions & 39 deletions .github/actions/discover-manifest-matrix/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions .github/actions/discover-manifest-matrix/action.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
# encrypt-env

Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release.

The release must exist before this action runs.

## Usage

```yaml
- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main
with:
manifest: vaults/mainframe.yml # required
keys-directory: keys # default: keys
release-tag: ${{ needs.release.outputs.tag }} # required, must already exist
release-repo: "" # default: current repository
token: ${{ secrets.GITHUB_TOKEN }} # required
env:
GITHUB_SECRETS_JSON: ${{ toJson(secrets) }}
GITHUB_VARS_JSON: ${{ toJson(vars) }}
```

The calling job requires:

```yaml
permissions:
contents: write
```

## Manifest

```yaml
asset: mainframe.sops.env
keys:
- mainframe
apps:
- traefik
- rybbit
env:
APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name
APPS_TIMEZONE: APPS_TIMEZONE
```

The action renders `apps` as the comma-separated `APPS` dotenv value. For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
108 changes: 108 additions & 0 deletions .github/actions/encrypt-env/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
name: Encrypt env
description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release.
inputs:
manifest:
description: Path to the YAML env manifest.
required: true
release-repo:
description: GitHub repository containing the release. Defaults to the current repository.
required: false
default: ""
release-tag:
description: Existing release tag to publish the asset to.
required: true
keys-directory:
description: Directory containing public age recipients named <key>.pub.
required: false
default: keys
token:
description: Token with permission to write releases.
required: true
outputs:
release-repo:
description: GitHub repository used for publication.
value: ${{ steps.publish.outputs.release_repo }}
tag:
description: Release tag used for publication.
value: ${{ steps.publish.outputs.tag }}
ref:
description: Release asset ref in owner/repo@tag:asset format.
value: ${{ steps.publish.outputs.ref }}
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
env:
SOPS_VERSION: "3.13.1"
run: |
python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt"
mkdir -p "$RUNNER_TEMP/encrypt-env/bin"
curl --fail --location --silent --show-error \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
--output "$RUNNER_TEMP/encrypt-env/bin/sops"
chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops"
echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH"
- name: Render env
id: render
shell: bash
env:
MANIFEST: ${{ inputs.manifest }}
run: |
mkdir -p "$RUNNER_TEMP/encrypt-env"
python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \
--manifest "$MANIFEST" \
--output "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Resolve release target
id: target
shell: bash
env:
INPUT_RELEASE_REPO: ${{ inputs.release-repo }}
DEFAULT_RELEASE_REPO: ${{ github.repository }}
ASSET_NAME: ${{ steps.render.outputs.asset }}
run: |
release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}"
echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Encrypt env
shell: bash
env:
KEYS: ${{ steps.render.outputs.keys }}
KEYS_DIRECTORY: ${{ inputs.keys-directory }}
run: |
recipients=""
IFS=',' read -ra names <<< "$KEYS"
for name in "${names[@]}"; do
recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")"
if [[ ! "$recipient" =~ ^age1 ]]; then
echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2
exit 1
fi
recipients="${recipients:+$recipients,}$recipient"
done
SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \
--input-type dotenv \
--output-type dotenv \
"$RUNNER_TEMP/encrypt-env/plain.env" \
> "$RUNNER_TEMP/encrypt-env/.sops.env"
rm "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Upload encrypted env
id: publish
shell: bash
env:
RELEASE_REPO: ${{ steps.target.outputs.release_repo }}
TAG: ${{ inputs.release-tag }}
ASSET_NAME: ${{ steps.target.outputs.asset_name }}
GH_TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME"
cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path"
gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber
echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Cleanup
if: always()
shell: bash
run: rm -rf "$RUNNER_TEMP/encrypt-env"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,8 @@
ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$")
KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")
APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")


class ManifestError(Exception):
Expand DownExpand Up@@ -59,32 +58,31 @@ def load_manifest(path):
raise ManifestError(f"{path} is not valid YAML: {exc}") from exc
if not isinstance(manifest, dict):
raise ManifestError(f"{path} must contain a YAML mapping")
if "package" in manifest:
raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration")
release_repo = manifest.get("release_repo")
release_tag = manifest.get("release_tag")
release_asset = manifest.get("release_asset", f"{path.stem}.sops.env")
unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"})
if unknown:
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
asset = manifest.get("asset")
keys = manifest.get("keys")
apps = manifest.get("apps")
env = manifest.get("env")
if "raw_env" in manifest:
raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax")
if release_repo is not None and (
not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo)
):
raise ManifestError("release_repo must be in owner/repo format")
if release_tag is not None and (
not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag)
):
raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens")
if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset):
raise ManifestError("release_asset must be named like server.sops.env")
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
raise ManifestError("asset must be named like server.sops.env")
if not isinstance(keys, list) or not keys:
raise ManifestError("keys must be a non-empty list")
if not isinstance(apps, list) or not apps:
raise ManifestError("apps must be a non-empty list")
if not isinstance(env, dict) or not env:
raise ManifestError("env must be a non-empty mapping")
for key in keys:
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
raise ManifestError(f"invalid key name: {key!r}")
for app in apps:
if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app):
raise ManifestError(f"invalid app name: {app!r}")
if len(apps) != len(set(apps)):
raise ManifestError("apps contains duplicate app names")
if "APPS" in env:
raise ManifestError("APPS must be configured through apps")
for output_name, source_name in env.items():
if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name):
raise ManifestError(f"invalid output env name: {output_name!r}")
Expand DownExpand Up@@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables):


def render_env(manifest, secrets, variables):
lines = []
lines = [f"APPS={','.join(manifest['apps'])}"]
missing = []
for output_name, source_name in manifest["env"].items():
value = resolve_value(source_name, secrets, variables)
Expand DownExpand Up@@ -148,9 +146,7 @@ def main(argv=None):
args.output.chmod(0o600)
write_github_outputs(
{
"release_repo": manifest.get("release_repo", ""),
"release_tag": manifest.get("release_tag", ""),
"release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"),
"asset": manifest["asset"],
"keys": ",".join(manifest["keys"]),
}
)
Expand Down
Loading
, '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('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f14cbd
feat: deploy rybbit for rubykatzen.com through flightdeck itself
ineedjet Aug 18, 2026
7cbcf65
refactor: rename projects/rubykatzen/rubykatzen-com.yml to targets/ha…
ineedjet Aug 18, 2026
0365987
refactor: name key, release tag, and asset after the target (hawkeye)
ineedjet Aug 18, 2026
db021de
feat: add publish-sops-env-shared.yml, use it as our own consumer
ineedjet Aug 18, 2026
20d9768
refactor!: replace deploy-shared.yml's raw extra-vars JSON with a cle…
ineedjet Aug 18, 2026
6f970f6
refactor: rename consumer workflows to target-named (hawkeye)
ineedjet Aug 18, 2026
2e3f001
feat: rename deploy-hawkeye.yml to deploy.yml, add a target selector
ineedjet Aug 18, 2026
241081d
feat: rename release-please.yml to release.yml, auto-trigger deploy a…
ineedjet Aug 18, 2026
3f44408
refactor!: make app-ref required, drop the checkout-ref fallback default
ineedjet Aug 18, 2026
8f3286e
feat: extract upload-bundle-shared.yml, use it from release.yml
ineedjet Aug 18, 2026
1e192a5
refactor!: bake bundle config into upload-bundle-shared.yml, split of…
ineedjet Aug 18, 2026
2b7b16a
refactor: publish hawkeye's encrypted env as part of the release, not…
ineedjet Aug 18, 2026
1090efd
feat: add hawkeye-env job to release.yml
ineedjet Aug 18, 2026
d1472bb
chore: remove unused discover-manifest-matrix action
ineedjet Aug 18, 2026
fe108e7
docs: remove discover-manifest-matrix from README
ineedjet Aug 18, 2026
74e4d02
refactor: merge build-bundle and upload into one composite action
ineedjet Aug 18, 2026
e7aed57
fix: make shared deployment workflows runnable
ineedjet Aug 18, 2026
7558bd4
refactor!: replace asset workflows with actions
ineedjet Aug 18, 2026
14f7e14
refactor: publish env to the created release
ineedjet Aug 18, 2026
a18ec62
feat: drive encryption and deploys from targets
ineedjet Aug 18, 2026
a1db561
feat: define target apps in encryption config
ineedjet Aug 19, 2026
ae0caa1
refactor: use default target paths
ineedjet Aug 19, 2026
c57535c
refactor: hide ansible inventory from deploy interface
ineedjet Aug 19, 2026
9463898
refactor: describe deploy hosts as ssh destinations
ineedjet Aug 19, 2026
f1100c2
refactor: separate encryption configs from deploy targets
ineedjet Aug 19, 2026
2c1f604
fix: gate automatic deploy on an actual release, not any Release run
ineedjet Aug 19, 2026
c6d01db
refactor: rename load-targets to load-matrix
ineedjet Aug 19, 2026
b6aad6a
refactor: make load-matrix a dumb directory-to-matrix reader
ineedjet Aug 19, 2026
18a13a0
refactor: rename encrypt/ to envs/
ineedjet Aug 19, 2026
9a419a3
refactor: rename envs/ to sops/
ineedjet Aug 19, 2026
9ba35e3
refactor: rename sops/ to vaults/
ineedjet Aug 19, 2026
ae6f6a1
refactor: homogenize matrix job naming across release.yml and deploy.yml
ineedjet Aug 19, 2026
285b1e1
refactor: publish flightdeck's apps/ as its own release asset
ineedjet Aug 19, 2026
5976f34
refactor: split build-bundle, push paths/bundle-name into overridable…
ineedjet Aug 19, 2026
e43cfd7
docs: mark path/sops_age_key_file as optional in the targets example
ineedjet Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/deploy-shared.yml:
# actionlint 1.7.12 predates these GitHub job context properties.
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
31 changes: 31 additions & 0 deletions .github/actions/build-apps-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
name: Build and upload apps bundle
description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: false
default: apps
bundle-name:
description: Bundle archive filename.
required: false
default: flightdeck-apps.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
value: ${{ steps.build.outputs.bundle-path }}
runs:
using: composite
steps:
- uses: ./.github/actions/build-bundle
id: build
with:
paths: ${{ inputs.paths }}
bundle-name: ${{ inputs.bundle-name }}
release-tag: ${{ inputs.release-tag }}
token: ${{ inputs.token }}
33 changes: 29 additions & 4 deletions .github/actions/build-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
name: Build app bundle
description: Build and validate a zip bundle from specified paths.
name: Build and upload bundle
description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: true
required: false
default: |
ansible.cfg
.env.example
backup.sh
deploy.sh
down.sh
generate-env.sh
lib.sh
logs.sh
restart.sh
up.sh
README.md
bundle-name:
description: Bundle archive filename.
required: false
default: bundle.zip
default: flightdeck.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
Expand DownExpand Up@@ -40,3 +58,10 @@ runs:
exit 1
fi
echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT"
- name: Upload bundle
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }}
run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber
39 changes: 0 additions & 39 deletions .github/actions/discover-manifest-matrix/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions .github/actions/discover-manifest-matrix/action.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
# encrypt-env

Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release.

The release must exist before this action runs.

## Usage

```yaml
- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main
with:
manifest: vaults/mainframe.yml # required
keys-directory: keys # default: keys
release-tag: ${{ needs.release.outputs.tag }} # required, must already exist
release-repo: "" # default: current repository
token: ${{ secrets.GITHUB_TOKEN }} # required
env:
GITHUB_SECRETS_JSON: ${{ toJson(secrets) }}
GITHUB_VARS_JSON: ${{ toJson(vars) }}
```

The calling job requires:

```yaml
permissions:
contents: write
```

## Manifest

```yaml
asset: mainframe.sops.env
keys:
- mainframe
apps:
- traefik
- rybbit
env:
APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name
APPS_TIMEZONE: APPS_TIMEZONE
```

The action renders `apps` as the comma-separated `APPS` dotenv value. For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
108 changes: 108 additions & 0 deletions .github/actions/encrypt-env/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
name: Encrypt env
description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release.
inputs:
manifest:
description: Path to the YAML env manifest.
required: true
release-repo:
description: GitHub repository containing the release. Defaults to the current repository.
required: false
default: ""
release-tag:
description: Existing release tag to publish the asset to.
required: true
keys-directory:
description: Directory containing public age recipients named <key>.pub.
required: false
default: keys
token:
description: Token with permission to write releases.
required: true
outputs:
release-repo:
description: GitHub repository used for publication.
value: ${{ steps.publish.outputs.release_repo }}
tag:
description: Release tag used for publication.
value: ${{ steps.publish.outputs.tag }}
ref:
description: Release asset ref in owner/repo@tag:asset format.
value: ${{ steps.publish.outputs.ref }}
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
env:
SOPS_VERSION: "3.13.1"
run: |
python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt"
mkdir -p "$RUNNER_TEMP/encrypt-env/bin"
curl --fail --location --silent --show-error \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
--output "$RUNNER_TEMP/encrypt-env/bin/sops"
chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops"
echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH"
- name: Render env
id: render
shell: bash
env:
MANIFEST: ${{ inputs.manifest }}
run: |
mkdir -p "$RUNNER_TEMP/encrypt-env"
python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \
--manifest "$MANIFEST" \
--output "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Resolve release target
id: target
shell: bash
env:
INPUT_RELEASE_REPO: ${{ inputs.release-repo }}
DEFAULT_RELEASE_REPO: ${{ github.repository }}
ASSET_NAME: ${{ steps.render.outputs.asset }}
run: |
release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}"
echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Encrypt env
shell: bash
env:
KEYS: ${{ steps.render.outputs.keys }}
KEYS_DIRECTORY: ${{ inputs.keys-directory }}
run: |
recipients=""
IFS=',' read -ra names <<< "$KEYS"
for name in "${names[@]}"; do
recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")"
if [[ ! "$recipient" =~ ^age1 ]]; then
echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2
exit 1
fi
recipients="${recipients:+$recipients,}$recipient"
done
SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \
--input-type dotenv \
--output-type dotenv \
"$RUNNER_TEMP/encrypt-env/plain.env" \
> "$RUNNER_TEMP/encrypt-env/.sops.env"
rm "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Upload encrypted env
id: publish
shell: bash
env:
RELEASE_REPO: ${{ steps.target.outputs.release_repo }}
TAG: ${{ inputs.release-tag }}
ASSET_NAME: ${{ steps.target.outputs.asset_name }}
GH_TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME"
cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path"
gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber
echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Cleanup
if: always()
shell: bash
run: rm -rf "$RUNNER_TEMP/encrypt-env"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,8 @@
ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$")
KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")
APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")


class ManifestError(Exception):
Expand DownExpand Up@@ -59,32 +58,31 @@ def load_manifest(path):
raise ManifestError(f"{path} is not valid YAML: {exc}") from exc
if not isinstance(manifest, dict):
raise ManifestError(f"{path} must contain a YAML mapping")
if "package" in manifest:
raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration")
release_repo = manifest.get("release_repo")
release_tag = manifest.get("release_tag")
release_asset = manifest.get("release_asset", f"{path.stem}.sops.env")
unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"})
if unknown:
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
asset = manifest.get("asset")
keys = manifest.get("keys")
apps = manifest.get("apps")
env = manifest.get("env")
if "raw_env" in manifest:
raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax")
if release_repo is not None and (
not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo)
):
raise ManifestError("release_repo must be in owner/repo format")
if release_tag is not None and (
not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag)
):
raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens")
if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset):
raise ManifestError("release_asset must be named like server.sops.env")
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
raise ManifestError("asset must be named like server.sops.env")
if not isinstance(keys, list) or not keys:
raise ManifestError("keys must be a non-empty list")
if not isinstance(apps, list) or not apps:
raise ManifestError("apps must be a non-empty list")
if not isinstance(env, dict) or not env:
raise ManifestError("env must be a non-empty mapping")
for key in keys:
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
raise ManifestError(f"invalid key name: {key!r}")
for app in apps:
if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app):
raise ManifestError(f"invalid app name: {app!r}")
if len(apps) != len(set(apps)):
raise ManifestError("apps contains duplicate app names")
if "APPS" in env:
raise ManifestError("APPS must be configured through apps")
for output_name, source_name in env.items():
if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name):
raise ManifestError(f"invalid output env name: {output_name!r}")
Expand DownExpand Up@@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables):


def render_env(manifest, secrets, variables):
lines = []
lines = [f"APPS={','.join(manifest['apps'])}"]
missing = []
for output_name, source_name in manifest["env"].items():
value = resolve_value(source_name, secrets, variables)
Expand DownExpand Up@@ -148,9 +146,7 @@ def main(argv=None):
args.output.chmod(0o600)
write_github_outputs(
{
"release_repo": manifest.get("release_repo", ""),
"release_tag": manifest.get("release_tag", ""),
"release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"),
"asset": manifest["asset"],
"keys": ",".join(manifest["keys"]),
}
)
Expand Down
Loading
, '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" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f14cbd
feat: deploy rybbit for rubykatzen.com through flightdeck itself
ineedjet Aug 18, 2026
7cbcf65
refactor: rename projects/rubykatzen/rubykatzen-com.yml to targets/ha…
ineedjet Aug 18, 2026
0365987
refactor: name key, release tag, and asset after the target (hawkeye)
ineedjet Aug 18, 2026
db021de
feat: add publish-sops-env-shared.yml, use it as our own consumer
ineedjet Aug 18, 2026
20d9768
refactor!: replace deploy-shared.yml's raw extra-vars JSON with a cle…
ineedjet Aug 18, 2026
6f970f6
refactor: rename consumer workflows to target-named (hawkeye)
ineedjet Aug 18, 2026
2e3f001
feat: rename deploy-hawkeye.yml to deploy.yml, add a target selector
ineedjet Aug 18, 2026
241081d
feat: rename release-please.yml to release.yml, auto-trigger deploy a…
ineedjet Aug 18, 2026
3f44408
refactor!: make app-ref required, drop the checkout-ref fallback default
ineedjet Aug 18, 2026
8f3286e
feat: extract upload-bundle-shared.yml, use it from release.yml
ineedjet Aug 18, 2026
1e192a5
refactor!: bake bundle config into upload-bundle-shared.yml, split of…
ineedjet Aug 18, 2026
2b7b16a
refactor: publish hawkeye's encrypted env as part of the release, not…
ineedjet Aug 18, 2026
1090efd
feat: add hawkeye-env job to release.yml
ineedjet Aug 18, 2026
d1472bb
chore: remove unused discover-manifest-matrix action
ineedjet Aug 18, 2026
fe108e7
docs: remove discover-manifest-matrix from README
ineedjet Aug 18, 2026
74e4d02
refactor: merge build-bundle and upload into one composite action
ineedjet Aug 18, 2026
e7aed57
fix: make shared deployment workflows runnable
ineedjet Aug 18, 2026
7558bd4
refactor!: replace asset workflows with actions
ineedjet Aug 18, 2026
14f7e14
refactor: publish env to the created release
ineedjet Aug 18, 2026
a18ec62
feat: drive encryption and deploys from targets
ineedjet Aug 18, 2026
a1db561
feat: define target apps in encryption config
ineedjet Aug 19, 2026
ae0caa1
refactor: use default target paths
ineedjet Aug 19, 2026
c57535c
refactor: hide ansible inventory from deploy interface
ineedjet Aug 19, 2026
9463898
refactor: describe deploy hosts as ssh destinations
ineedjet Aug 19, 2026
f1100c2
refactor: separate encryption configs from deploy targets
ineedjet Aug 19, 2026
2c1f604
fix: gate automatic deploy on an actual release, not any Release run
ineedjet Aug 19, 2026
c6d01db
refactor: rename load-targets to load-matrix
ineedjet Aug 19, 2026
b6aad6a
refactor: make load-matrix a dumb directory-to-matrix reader
ineedjet Aug 19, 2026
18a13a0
refactor: rename encrypt/ to envs/
ineedjet Aug 19, 2026
9a419a3
refactor: rename envs/ to sops/
ineedjet Aug 19, 2026
9ba35e3
refactor: rename sops/ to vaults/
ineedjet Aug 19, 2026
ae6f6a1
refactor: homogenize matrix job naming across release.yml and deploy.yml
ineedjet Aug 19, 2026
285b1e1
refactor: publish flightdeck's apps/ as its own release asset
ineedjet Aug 19, 2026
5976f34
refactor: split build-bundle, push paths/bundle-name into overridable…
ineedjet Aug 19, 2026
e43cfd7
docs: mark path/sops_age_key_file as optional in the targets example
ineedjet Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/deploy-shared.yml:
# actionlint 1.7.12 predates these GitHub job context properties.
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
31 changes: 31 additions & 0 deletions .github/actions/build-apps-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
name: Build and upload apps bundle
description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: false
default: apps
bundle-name:
description: Bundle archive filename.
required: false
default: flightdeck-apps.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
value: ${{ steps.build.outputs.bundle-path }}
runs:
using: composite
steps:
- uses: ./.github/actions/build-bundle
id: build
with:
paths: ${{ inputs.paths }}
bundle-name: ${{ inputs.bundle-name }}
release-tag: ${{ inputs.release-tag }}
token: ${{ inputs.token }}
33 changes: 29 additions & 4 deletions .github/actions/build-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
name: Build app bundle
description: Build and validate a zip bundle from specified paths.
name: Build and upload bundle
description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: true
required: false
default: |
ansible.cfg
.env.example
backup.sh
deploy.sh
down.sh
generate-env.sh
lib.sh
logs.sh
restart.sh
up.sh
README.md
bundle-name:
description: Bundle archive filename.
required: false
default: bundle.zip
default: flightdeck.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
Expand DownExpand Up@@ -40,3 +58,10 @@ runs:
exit 1
fi
echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT"
- name: Upload bundle
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }}
run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber
39 changes: 0 additions & 39 deletions .github/actions/discover-manifest-matrix/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions .github/actions/discover-manifest-matrix/action.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
# encrypt-env

Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release.

The release must exist before this action runs.

## Usage

```yaml
- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main
with:
manifest: vaults/mainframe.yml # required
keys-directory: keys # default: keys
release-tag: ${{ needs.release.outputs.tag }} # required, must already exist
release-repo: "" # default: current repository
token: ${{ secrets.GITHUB_TOKEN }} # required
env:
GITHUB_SECRETS_JSON: ${{ toJson(secrets) }}
GITHUB_VARS_JSON: ${{ toJson(vars) }}
```

The calling job requires:

```yaml
permissions:
contents: write
```

## Manifest

```yaml
asset: mainframe.sops.env
keys:
- mainframe
apps:
- traefik
- rybbit
env:
APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name
APPS_TIMEZONE: APPS_TIMEZONE
```

The action renders `apps` as the comma-separated `APPS` dotenv value. For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
108 changes: 108 additions & 0 deletions .github/actions/encrypt-env/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
name: Encrypt env
description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release.
inputs:
manifest:
description: Path to the YAML env manifest.
required: true
release-repo:
description: GitHub repository containing the release. Defaults to the current repository.
required: false
default: ""
release-tag:
description: Existing release tag to publish the asset to.
required: true
keys-directory:
description: Directory containing public age recipients named <key>.pub.
required: false
default: keys
token:
description: Token with permission to write releases.
required: true
outputs:
release-repo:
description: GitHub repository used for publication.
value: ${{ steps.publish.outputs.release_repo }}
tag:
description: Release tag used for publication.
value: ${{ steps.publish.outputs.tag }}
ref:
description: Release asset ref in owner/repo@tag:asset format.
value: ${{ steps.publish.outputs.ref }}
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
env:
SOPS_VERSION: "3.13.1"
run: |
python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt"
mkdir -p "$RUNNER_TEMP/encrypt-env/bin"
curl --fail --location --silent --show-error \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
--output "$RUNNER_TEMP/encrypt-env/bin/sops"
chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops"
echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH"
- name: Render env
id: render
shell: bash
env:
MANIFEST: ${{ inputs.manifest }}
run: |
mkdir -p "$RUNNER_TEMP/encrypt-env"
python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \
--manifest "$MANIFEST" \
--output "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Resolve release target
id: target
shell: bash
env:
INPUT_RELEASE_REPO: ${{ inputs.release-repo }}
DEFAULT_RELEASE_REPO: ${{ github.repository }}
ASSET_NAME: ${{ steps.render.outputs.asset }}
run: |
release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}"
echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Encrypt env
shell: bash
env:
KEYS: ${{ steps.render.outputs.keys }}
KEYS_DIRECTORY: ${{ inputs.keys-directory }}
run: |
recipients=""
IFS=',' read -ra names <<< "$KEYS"
for name in "${names[@]}"; do
recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")"
if [[ ! "$recipient" =~ ^age1 ]]; then
echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2
exit 1
fi
recipients="${recipients:+$recipients,}$recipient"
done
SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \
--input-type dotenv \
--output-type dotenv \
"$RUNNER_TEMP/encrypt-env/plain.env" \
> "$RUNNER_TEMP/encrypt-env/.sops.env"
rm "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Upload encrypted env
id: publish
shell: bash
env:
RELEASE_REPO: ${{ steps.target.outputs.release_repo }}
TAG: ${{ inputs.release-tag }}
ASSET_NAME: ${{ steps.target.outputs.asset_name }}
GH_TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME"
cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path"
gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber
echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Cleanup
if: always()
shell: bash
run: rm -rf "$RUNNER_TEMP/encrypt-env"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,8 @@
ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$")
KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")
APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")


class ManifestError(Exception):
Expand DownExpand Up@@ -59,32 +58,31 @@ def load_manifest(path):
raise ManifestError(f"{path} is not valid YAML: {exc}") from exc
if not isinstance(manifest, dict):
raise ManifestError(f"{path} must contain a YAML mapping")
if "package" in manifest:
raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration")
release_repo = manifest.get("release_repo")
release_tag = manifest.get("release_tag")
release_asset = manifest.get("release_asset", f"{path.stem}.sops.env")
unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"})
if unknown:
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
asset = manifest.get("asset")
keys = manifest.get("keys")
apps = manifest.get("apps")
env = manifest.get("env")
if "raw_env" in manifest:
raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax")
if release_repo is not None and (
not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo)
):
raise ManifestError("release_repo must be in owner/repo format")
if release_tag is not None and (
not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag)
):
raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens")
if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset):
raise ManifestError("release_asset must be named like server.sops.env")
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
raise ManifestError("asset must be named like server.sops.env")
if not isinstance(keys, list) or not keys:
raise ManifestError("keys must be a non-empty list")
if not isinstance(apps, list) or not apps:
raise ManifestError("apps must be a non-empty list")
if not isinstance(env, dict) or not env:
raise ManifestError("env must be a non-empty mapping")
for key in keys:
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
raise ManifestError(f"invalid key name: {key!r}")
for app in apps:
if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app):
raise ManifestError(f"invalid app name: {app!r}")
if len(apps) != len(set(apps)):
raise ManifestError("apps contains duplicate app names")
if "APPS" in env:
raise ManifestError("APPS must be configured through apps")
for output_name, source_name in env.items():
if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name):
raise ManifestError(f"invalid output env name: {output_name!r}")
Expand DownExpand Up@@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables):


def render_env(manifest, secrets, variables):
lines = []
lines = [f"APPS={','.join(manifest['apps'])}"]
missing = []
for output_name, source_name in manifest["env"].items():
value = resolve_value(source_name, secrets, variables)
Expand DownExpand Up@@ -148,9 +146,7 @@ def main(argv=None):
args.output.chmod(0o600)
write_github_outputs(
{
"release_repo": manifest.get("release_repo", ""),
"release_tag": manifest.get("release_tag", ""),
"release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"),
"asset": manifest["asset"],
"keys": ",".join(manifest["keys"]),
}
)
Expand Down
Loading
, '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('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f14cbd
feat: deploy rybbit for rubykatzen.com through flightdeck itself
ineedjet Aug 18, 2026
7cbcf65
refactor: rename projects/rubykatzen/rubykatzen-com.yml to targets/ha…
ineedjet Aug 18, 2026
0365987
refactor: name key, release tag, and asset after the target (hawkeye)
ineedjet Aug 18, 2026
db021de
feat: add publish-sops-env-shared.yml, use it as our own consumer
ineedjet Aug 18, 2026
20d9768
refactor!: replace deploy-shared.yml's raw extra-vars JSON with a cle…
ineedjet Aug 18, 2026
6f970f6
refactor: rename consumer workflows to target-named (hawkeye)
ineedjet Aug 18, 2026
2e3f001
feat: rename deploy-hawkeye.yml to deploy.yml, add a target selector
ineedjet Aug 18, 2026
241081d
feat: rename release-please.yml to release.yml, auto-trigger deploy a…
ineedjet Aug 18, 2026
3f44408
refactor!: make app-ref required, drop the checkout-ref fallback default
ineedjet Aug 18, 2026
8f3286e
feat: extract upload-bundle-shared.yml, use it from release.yml
ineedjet Aug 18, 2026
1e192a5
refactor!: bake bundle config into upload-bundle-shared.yml, split of…
ineedjet Aug 18, 2026
2b7b16a
refactor: publish hawkeye's encrypted env as part of the release, not…
ineedjet Aug 18, 2026
1090efd
feat: add hawkeye-env job to release.yml
ineedjet Aug 18, 2026
d1472bb
chore: remove unused discover-manifest-matrix action
ineedjet Aug 18, 2026
fe108e7
docs: remove discover-manifest-matrix from README
ineedjet Aug 18, 2026
74e4d02
refactor: merge build-bundle and upload into one composite action
ineedjet Aug 18, 2026
e7aed57
fix: make shared deployment workflows runnable
ineedjet Aug 18, 2026
7558bd4
refactor!: replace asset workflows with actions
ineedjet Aug 18, 2026
14f7e14
refactor: publish env to the created release
ineedjet Aug 18, 2026
a18ec62
feat: drive encryption and deploys from targets
ineedjet Aug 18, 2026
a1db561
feat: define target apps in encryption config
ineedjet Aug 19, 2026
ae0caa1
refactor: use default target paths
ineedjet Aug 19, 2026
c57535c
refactor: hide ansible inventory from deploy interface
ineedjet Aug 19, 2026
9463898
refactor: describe deploy hosts as ssh destinations
ineedjet Aug 19, 2026
f1100c2
refactor: separate encryption configs from deploy targets
ineedjet Aug 19, 2026
2c1f604
fix: gate automatic deploy on an actual release, not any Release run
ineedjet Aug 19, 2026
c6d01db
refactor: rename load-targets to load-matrix
ineedjet Aug 19, 2026
b6aad6a
refactor: make load-matrix a dumb directory-to-matrix reader
ineedjet Aug 19, 2026
18a13a0
refactor: rename encrypt/ to envs/
ineedjet Aug 19, 2026
9a419a3
refactor: rename envs/ to sops/
ineedjet Aug 19, 2026
9ba35e3
refactor: rename sops/ to vaults/
ineedjet Aug 19, 2026
ae6f6a1
refactor: homogenize matrix job naming across release.yml and deploy.yml
ineedjet Aug 19, 2026
285b1e1
refactor: publish flightdeck's apps/ as its own release asset
ineedjet Aug 19, 2026
5976f34
refactor: split build-bundle, push paths/bundle-name into overridable…
ineedjet Aug 19, 2026
e43cfd7
docs: mark path/sops_age_key_file as optional in the targets example
ineedjet Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/deploy-shared.yml:
# actionlint 1.7.12 predates these GitHub job context properties.
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
31 changes: 31 additions & 0 deletions .github/actions/build-apps-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
name: Build and upload apps bundle
description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: false
default: apps
bundle-name:
description: Bundle archive filename.
required: false
default: flightdeck-apps.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
value: ${{ steps.build.outputs.bundle-path }}
runs:
using: composite
steps:
- uses: ./.github/actions/build-bundle
id: build
with:
paths: ${{ inputs.paths }}
bundle-name: ${{ inputs.bundle-name }}
release-tag: ${{ inputs.release-tag }}
token: ${{ inputs.token }}
33 changes: 29 additions & 4 deletions .github/actions/build-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
name: Build app bundle
description: Build and validate a zip bundle from specified paths.
name: Build and upload bundle
description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: true
required: false
default: |
ansible.cfg
.env.example
backup.sh
deploy.sh
down.sh
generate-env.sh
lib.sh
logs.sh
restart.sh
up.sh
README.md
bundle-name:
description: Bundle archive filename.
required: false
default: bundle.zip
default: flightdeck.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
Expand DownExpand Up@@ -40,3 +58,10 @@ runs:
exit 1
fi
echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT"
- name: Upload bundle
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }}
run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber
39 changes: 0 additions & 39 deletions .github/actions/discover-manifest-matrix/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions .github/actions/discover-manifest-matrix/action.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
# encrypt-env

Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release.

The release must exist before this action runs.

## Usage

```yaml
- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main
with:
manifest: vaults/mainframe.yml # required
keys-directory: keys # default: keys
release-tag: ${{ needs.release.outputs.tag }} # required, must already exist
release-repo: "" # default: current repository
token: ${{ secrets.GITHUB_TOKEN }} # required
env:
GITHUB_SECRETS_JSON: ${{ toJson(secrets) }}
GITHUB_VARS_JSON: ${{ toJson(vars) }}
```

The calling job requires:

```yaml
permissions:
contents: write
```

## Manifest

```yaml
asset: mainframe.sops.env
keys:
- mainframe
apps:
- traefik
- rybbit
env:
APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name
APPS_TIMEZONE: APPS_TIMEZONE
```

The action renders `apps` as the comma-separated `APPS` dotenv value. For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
108 changes: 108 additions & 0 deletions .github/actions/encrypt-env/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
name: Encrypt env
description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release.
inputs:
manifest:
description: Path to the YAML env manifest.
required: true
release-repo:
description: GitHub repository containing the release. Defaults to the current repository.
required: false
default: ""
release-tag:
description: Existing release tag to publish the asset to.
required: true
keys-directory:
description: Directory containing public age recipients named <key>.pub.
required: false
default: keys
token:
description: Token with permission to write releases.
required: true
outputs:
release-repo:
description: GitHub repository used for publication.
value: ${{ steps.publish.outputs.release_repo }}
tag:
description: Release tag used for publication.
value: ${{ steps.publish.outputs.tag }}
ref:
description: Release asset ref in owner/repo@tag:asset format.
value: ${{ steps.publish.outputs.ref }}
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
env:
SOPS_VERSION: "3.13.1"
run: |
python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt"
mkdir -p "$RUNNER_TEMP/encrypt-env/bin"
curl --fail --location --silent --show-error \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
--output "$RUNNER_TEMP/encrypt-env/bin/sops"
chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops"
echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH"
- name: Render env
id: render
shell: bash
env:
MANIFEST: ${{ inputs.manifest }}
run: |
mkdir -p "$RUNNER_TEMP/encrypt-env"
python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \
--manifest "$MANIFEST" \
--output "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Resolve release target
id: target
shell: bash
env:
INPUT_RELEASE_REPO: ${{ inputs.release-repo }}
DEFAULT_RELEASE_REPO: ${{ github.repository }}
ASSET_NAME: ${{ steps.render.outputs.asset }}
run: |
release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}"
echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Encrypt env
shell: bash
env:
KEYS: ${{ steps.render.outputs.keys }}
KEYS_DIRECTORY: ${{ inputs.keys-directory }}
run: |
recipients=""
IFS=',' read -ra names <<< "$KEYS"
for name in "${names[@]}"; do
recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")"
if [[ ! "$recipient" =~ ^age1 ]]; then
echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2
exit 1
fi
recipients="${recipients:+$recipients,}$recipient"
done
SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \
--input-type dotenv \
--output-type dotenv \
"$RUNNER_TEMP/encrypt-env/plain.env" \
> "$RUNNER_TEMP/encrypt-env/.sops.env"
rm "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Upload encrypted env
id: publish
shell: bash
env:
RELEASE_REPO: ${{ steps.target.outputs.release_repo }}
TAG: ${{ inputs.release-tag }}
ASSET_NAME: ${{ steps.target.outputs.asset_name }}
GH_TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME"
cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path"
gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber
echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Cleanup
if: always()
shell: bash
run: rm -rf "$RUNNER_TEMP/encrypt-env"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,8 @@
ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$")
KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")
APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")


class ManifestError(Exception):
Expand DownExpand Up@@ -59,32 +58,31 @@ def load_manifest(path):
raise ManifestError(f"{path} is not valid YAML: {exc}") from exc
if not isinstance(manifest, dict):
raise ManifestError(f"{path} must contain a YAML mapping")
if "package" in manifest:
raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration")
release_repo = manifest.get("release_repo")
release_tag = manifest.get("release_tag")
release_asset = manifest.get("release_asset", f"{path.stem}.sops.env")
unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"})
if unknown:
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
asset = manifest.get("asset")
keys = manifest.get("keys")
apps = manifest.get("apps")
env = manifest.get("env")
if "raw_env" in manifest:
raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax")
if release_repo is not None and (
not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo)
):
raise ManifestError("release_repo must be in owner/repo format")
if release_tag is not None and (
not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag)
):
raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens")
if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset):
raise ManifestError("release_asset must be named like server.sops.env")
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
raise ManifestError("asset must be named like server.sops.env")
if not isinstance(keys, list) or not keys:
raise ManifestError("keys must be a non-empty list")
if not isinstance(apps, list) or not apps:
raise ManifestError("apps must be a non-empty list")
if not isinstance(env, dict) or not env:
raise ManifestError("env must be a non-empty mapping")
for key in keys:
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
raise ManifestError(f"invalid key name: {key!r}")
for app in apps:
if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app):
raise ManifestError(f"invalid app name: {app!r}")
if len(apps) != len(set(apps)):
raise ManifestError("apps contains duplicate app names")
if "APPS" in env:
raise ManifestError("APPS must be configured through apps")
for output_name, source_name in env.items():
if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name):
raise ManifestError(f"invalid output env name: {output_name!r}")
Expand DownExpand Up@@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables):


def render_env(manifest, secrets, variables):
lines = []
lines = [f"APPS={','.join(manifest['apps'])}"]
missing = []
for output_name, source_name in manifest["env"].items():
value = resolve_value(source_name, secrets, variables)
Expand DownExpand Up@@ -148,9 +146,7 @@ def main(argv=None):
args.output.chmod(0o600)
write_github_outputs(
{
"release_repo": manifest.get("release_repo", ""),
"release_tag": manifest.get("release_tag", ""),
"release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"),
"asset": manifest["asset"],
"keys": ",".join(manifest["keys"]),
}
)
Expand Down
Loading
, '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('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f14cbd
feat: deploy rybbit for rubykatzen.com through flightdeck itself
ineedjet Aug 18, 2026
7cbcf65
refactor: rename projects/rubykatzen/rubykatzen-com.yml to targets/ha…
ineedjet Aug 18, 2026
0365987
refactor: name key, release tag, and asset after the target (hawkeye)
ineedjet Aug 18, 2026
db021de
feat: add publish-sops-env-shared.yml, use it as our own consumer
ineedjet Aug 18, 2026
20d9768
refactor!: replace deploy-shared.yml's raw extra-vars JSON with a cle…
ineedjet Aug 18, 2026
6f970f6
refactor: rename consumer workflows to target-named (hawkeye)
ineedjet Aug 18, 2026
2e3f001
feat: rename deploy-hawkeye.yml to deploy.yml, add a target selector
ineedjet Aug 18, 2026
241081d
feat: rename release-please.yml to release.yml, auto-trigger deploy a…
ineedjet Aug 18, 2026
3f44408
refactor!: make app-ref required, drop the checkout-ref fallback default
ineedjet Aug 18, 2026
8f3286e
feat: extract upload-bundle-shared.yml, use it from release.yml
ineedjet Aug 18, 2026
1e192a5
refactor!: bake bundle config into upload-bundle-shared.yml, split of…
ineedjet Aug 18, 2026
2b7b16a
refactor: publish hawkeye's encrypted env as part of the release, not…
ineedjet Aug 18, 2026
1090efd
feat: add hawkeye-env job to release.yml
ineedjet Aug 18, 2026
d1472bb
chore: remove unused discover-manifest-matrix action
ineedjet Aug 18, 2026
fe108e7
docs: remove discover-manifest-matrix from README
ineedjet Aug 18, 2026
74e4d02
refactor: merge build-bundle and upload into one composite action
ineedjet Aug 18, 2026
e7aed57
fix: make shared deployment workflows runnable
ineedjet Aug 18, 2026
7558bd4
refactor!: replace asset workflows with actions
ineedjet Aug 18, 2026
14f7e14
refactor: publish env to the created release
ineedjet Aug 18, 2026
a18ec62
feat: drive encryption and deploys from targets
ineedjet Aug 18, 2026
a1db561
feat: define target apps in encryption config
ineedjet Aug 19, 2026
ae0caa1
refactor: use default target paths
ineedjet Aug 19, 2026
c57535c
refactor: hide ansible inventory from deploy interface
ineedjet Aug 19, 2026
9463898
refactor: describe deploy hosts as ssh destinations
ineedjet Aug 19, 2026
f1100c2
refactor: separate encryption configs from deploy targets
ineedjet Aug 19, 2026
2c1f604
fix: gate automatic deploy on an actual release, not any Release run
ineedjet Aug 19, 2026
c6d01db
refactor: rename load-targets to load-matrix
ineedjet Aug 19, 2026
b6aad6a
refactor: make load-matrix a dumb directory-to-matrix reader
ineedjet Aug 19, 2026
18a13a0
refactor: rename encrypt/ to envs/
ineedjet Aug 19, 2026
9a419a3
refactor: rename envs/ to sops/
ineedjet Aug 19, 2026
9ba35e3
refactor: rename sops/ to vaults/
ineedjet Aug 19, 2026
ae6f6a1
refactor: homogenize matrix job naming across release.yml and deploy.yml
ineedjet Aug 19, 2026
285b1e1
refactor: publish flightdeck's apps/ as its own release asset
ineedjet Aug 19, 2026
5976f34
refactor: split build-bundle, push paths/bundle-name into overridable…
ineedjet Aug 19, 2026
e43cfd7
docs: mark path/sops_age_key_file as optional in the targets example
ineedjet Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/deploy-shared.yml:
# actionlint 1.7.12 predates these GitHub job context properties.
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
31 changes: 31 additions & 0 deletions .github/actions/build-apps-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
name: Build and upload apps bundle
description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: false
default: apps
bundle-name:
description: Bundle archive filename.
required: false
default: flightdeck-apps.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
value: ${{ steps.build.outputs.bundle-path }}
runs:
using: composite
steps:
- uses: ./.github/actions/build-bundle
id: build
with:
paths: ${{ inputs.paths }}
bundle-name: ${{ inputs.bundle-name }}
release-tag: ${{ inputs.release-tag }}
token: ${{ inputs.token }}
33 changes: 29 additions & 4 deletions .github/actions/build-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
name: Build app bundle
description: Build and validate a zip bundle from specified paths.
name: Build and upload bundle
description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: true
required: false
default: |
ansible.cfg
.env.example
backup.sh
deploy.sh
down.sh
generate-env.sh
lib.sh
logs.sh
restart.sh
up.sh
README.md
bundle-name:
description: Bundle archive filename.
required: false
default: bundle.zip
default: flightdeck.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
Expand DownExpand Up@@ -40,3 +58,10 @@ runs:
exit 1
fi
echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT"
- name: Upload bundle
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }}
run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber
39 changes: 0 additions & 39 deletions .github/actions/discover-manifest-matrix/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions .github/actions/discover-manifest-matrix/action.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
# encrypt-env

Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release.

The release must exist before this action runs.

## Usage

```yaml
- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main
with:
manifest: vaults/mainframe.yml # required
keys-directory: keys # default: keys
release-tag: ${{ needs.release.outputs.tag }} # required, must already exist
release-repo: "" # default: current repository
token: ${{ secrets.GITHUB_TOKEN }} # required
env:
GITHUB_SECRETS_JSON: ${{ toJson(secrets) }}
GITHUB_VARS_JSON: ${{ toJson(vars) }}
```

The calling job requires:

```yaml
permissions:
contents: write
```

## Manifest

```yaml
asset: mainframe.sops.env
keys:
- mainframe
apps:
- traefik
- rybbit
env:
APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name
APPS_TIMEZONE: APPS_TIMEZONE
```

The action renders `apps` as the comma-separated `APPS` dotenv value. For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
108 changes: 108 additions & 0 deletions .github/actions/encrypt-env/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
name: Encrypt env
description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release.
inputs:
manifest:
description: Path to the YAML env manifest.
required: true
release-repo:
description: GitHub repository containing the release. Defaults to the current repository.
required: false
default: ""
release-tag:
description: Existing release tag to publish the asset to.
required: true
keys-directory:
description: Directory containing public age recipients named <key>.pub.
required: false
default: keys
token:
description: Token with permission to write releases.
required: true
outputs:
release-repo:
description: GitHub repository used for publication.
value: ${{ steps.publish.outputs.release_repo }}
tag:
description: Release tag used for publication.
value: ${{ steps.publish.outputs.tag }}
ref:
description: Release asset ref in owner/repo@tag:asset format.
value: ${{ steps.publish.outputs.ref }}
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
env:
SOPS_VERSION: "3.13.1"
run: |
python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt"
mkdir -p "$RUNNER_TEMP/encrypt-env/bin"
curl --fail --location --silent --show-error \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
--output "$RUNNER_TEMP/encrypt-env/bin/sops"
chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops"
echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH"
- name: Render env
id: render
shell: bash
env:
MANIFEST: ${{ inputs.manifest }}
run: |
mkdir -p "$RUNNER_TEMP/encrypt-env"
python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \
--manifest "$MANIFEST" \
--output "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Resolve release target
id: target
shell: bash
env:
INPUT_RELEASE_REPO: ${{ inputs.release-repo }}
DEFAULT_RELEASE_REPO: ${{ github.repository }}
ASSET_NAME: ${{ steps.render.outputs.asset }}
run: |
release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}"
echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Encrypt env
shell: bash
env:
KEYS: ${{ steps.render.outputs.keys }}
KEYS_DIRECTORY: ${{ inputs.keys-directory }}
run: |
recipients=""
IFS=',' read -ra names <<< "$KEYS"
for name in "${names[@]}"; do
recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")"
if [[ ! "$recipient" =~ ^age1 ]]; then
echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2
exit 1
fi
recipients="${recipients:+$recipients,}$recipient"
done
SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \
--input-type dotenv \
--output-type dotenv \
"$RUNNER_TEMP/encrypt-env/plain.env" \
> "$RUNNER_TEMP/encrypt-env/.sops.env"
rm "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Upload encrypted env
id: publish
shell: bash
env:
RELEASE_REPO: ${{ steps.target.outputs.release_repo }}
TAG: ${{ inputs.release-tag }}
ASSET_NAME: ${{ steps.target.outputs.asset_name }}
GH_TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME"
cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path"
gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber
echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Cleanup
if: always()
shell: bash
run: rm -rf "$RUNNER_TEMP/encrypt-env"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,8 @@
ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$")
KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")
APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")


class ManifestError(Exception):
Expand DownExpand Up@@ -59,32 +58,31 @@ def load_manifest(path):
raise ManifestError(f"{path} is not valid YAML: {exc}") from exc
if not isinstance(manifest, dict):
raise ManifestError(f"{path} must contain a YAML mapping")
if "package" in manifest:
raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration")
release_repo = manifest.get("release_repo")
release_tag = manifest.get("release_tag")
release_asset = manifest.get("release_asset", f"{path.stem}.sops.env")
unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"})
if unknown:
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
asset = manifest.get("asset")
keys = manifest.get("keys")
apps = manifest.get("apps")
env = manifest.get("env")
if "raw_env" in manifest:
raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax")
if release_repo is not None and (
not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo)
):
raise ManifestError("release_repo must be in owner/repo format")
if release_tag is not None and (
not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag)
):
raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens")
if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset):
raise ManifestError("release_asset must be named like server.sops.env")
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
raise ManifestError("asset must be named like server.sops.env")
if not isinstance(keys, list) or not keys:
raise ManifestError("keys must be a non-empty list")
if not isinstance(apps, list) or not apps:
raise ManifestError("apps must be a non-empty list")
if not isinstance(env, dict) or not env:
raise ManifestError("env must be a non-empty mapping")
for key in keys:
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
raise ManifestError(f"invalid key name: {key!r}")
for app in apps:
if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app):
raise ManifestError(f"invalid app name: {app!r}")
if len(apps) != len(set(apps)):
raise ManifestError("apps contains duplicate app names")
if "APPS" in env:
raise ManifestError("APPS must be configured through apps")
for output_name, source_name in env.items():
if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name):
raise ManifestError(f"invalid output env name: {output_name!r}")
Expand DownExpand Up@@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables):


def render_env(manifest, secrets, variables):
lines = []
lines = [f"APPS={','.join(manifest['apps'])}"]
missing = []
for output_name, source_name in manifest["env"].items():
value = resolve_value(source_name, secrets, variables)
Expand DownExpand Up@@ -148,9 +146,7 @@ def main(argv=None):
args.output.chmod(0o600)
write_github_outputs(
{
"release_repo": manifest.get("release_repo", ""),
"release_tag": manifest.get("release_tag", ""),
"release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"),
"asset": manifest["asset"],
"keys": ",".join(manifest["keys"]),
}
)
Expand Down
Loading
, '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); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9f14cbd
feat: deploy rybbit for rubykatzen.com through flightdeck itself
ineedjet Aug 18, 2026
7cbcf65
refactor: rename projects/rubykatzen/rubykatzen-com.yml to targets/ha…
ineedjet Aug 18, 2026
0365987
refactor: name key, release tag, and asset after the target (hawkeye)
ineedjet Aug 18, 2026
db021de
feat: add publish-sops-env-shared.yml, use it as our own consumer
ineedjet Aug 18, 2026
20d9768
refactor!: replace deploy-shared.yml's raw extra-vars JSON with a cle…
ineedjet Aug 18, 2026
6f970f6
refactor: rename consumer workflows to target-named (hawkeye)
ineedjet Aug 18, 2026
2e3f001
feat: rename deploy-hawkeye.yml to deploy.yml, add a target selector
ineedjet Aug 18, 2026
241081d
feat: rename release-please.yml to release.yml, auto-trigger deploy a…
ineedjet Aug 18, 2026
3f44408
refactor!: make app-ref required, drop the checkout-ref fallback default
ineedjet Aug 18, 2026
8f3286e
feat: extract upload-bundle-shared.yml, use it from release.yml
ineedjet Aug 18, 2026
1e192a5
refactor!: bake bundle config into upload-bundle-shared.yml, split of…
ineedjet Aug 18, 2026
2b7b16a
refactor: publish hawkeye's encrypted env as part of the release, not…
ineedjet Aug 18, 2026
1090efd
feat: add hawkeye-env job to release.yml
ineedjet Aug 18, 2026
d1472bb
chore: remove unused discover-manifest-matrix action
ineedjet Aug 18, 2026
fe108e7
docs: remove discover-manifest-matrix from README
ineedjet Aug 18, 2026
74e4d02
refactor: merge build-bundle and upload into one composite action
ineedjet Aug 18, 2026
e7aed57
fix: make shared deployment workflows runnable
ineedjet Aug 18, 2026
7558bd4
refactor!: replace asset workflows with actions
ineedjet Aug 18, 2026
14f7e14
refactor: publish env to the created release
ineedjet Aug 18, 2026
a18ec62
feat: drive encryption and deploys from targets
ineedjet Aug 18, 2026
a1db561
feat: define target apps in encryption config
ineedjet Aug 19, 2026
ae0caa1
refactor: use default target paths
ineedjet Aug 19, 2026
c57535c
refactor: hide ansible inventory from deploy interface
ineedjet Aug 19, 2026
9463898
refactor: describe deploy hosts as ssh destinations
ineedjet Aug 19, 2026
f1100c2
refactor: separate encryption configs from deploy targets
ineedjet Aug 19, 2026
2c1f604
fix: gate automatic deploy on an actual release, not any Release run
ineedjet Aug 19, 2026
c6d01db
refactor: rename load-targets to load-matrix
ineedjet Aug 19, 2026
b6aad6a
refactor: make load-matrix a dumb directory-to-matrix reader
ineedjet Aug 19, 2026
18a13a0
refactor: rename encrypt/ to envs/
ineedjet Aug 19, 2026
9a419a3
refactor: rename envs/ to sops/
ineedjet Aug 19, 2026
9ba35e3
refactor: rename sops/ to vaults/
ineedjet Aug 19, 2026
ae6f6a1
refactor: homogenize matrix job naming across release.yml and deploy.yml
ineedjet Aug 19, 2026
285b1e1
refactor: publish flightdeck's apps/ as its own release asset
ineedjet Aug 19, 2026
5976f34
refactor: split build-bundle, push paths/bundle-name into overridable…
ineedjet Aug 19, 2026
e43cfd7
docs: mark path/sops_age_key_file as optional in the targets example
ineedjet Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actionlint.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
paths:
.github/workflows/deploy-shared.yml:
# actionlint 1.7.12 predates these GitHub job context properties.
ignore:
- 'property "workflow_(repository|sha)" is not defined in object type'
31 changes: 31 additions & 0 deletions .github/actions/build-apps-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
name: Build and upload apps bundle
description: Build an apps/ catalog bundle and upload it as a GitHub Release asset. Thin defaults wrapper around build-bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: false
default: apps
bundle-name:
description: Bundle archive filename.
required: false
default: flightdeck-apps.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
value: ${{ steps.build.outputs.bundle-path }}
runs:
using: composite
steps:
- uses: ./.github/actions/build-bundle
id: build
with:
paths: ${{ inputs.paths }}
bundle-name: ${{ inputs.bundle-name }}
release-tag: ${{ inputs.release-tag }}
token: ${{ inputs.token }}
33 changes: 29 additions & 4 deletions .github/actions/build-bundle/action.yml
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
name: Build app bundle
description: Build and validate a zip bundle from specified paths.
name: Build and upload bundle
description: Build a zip bundle from specified paths and upload it as a GitHub Release asset. Defaults to Flightdeck's own machinery bundle.
inputs:
paths:
description: Newline-separated paths to include in the bundle.
required: true
required: false
default: |
ansible.cfg
.env.example
backup.sh
deploy.sh
down.sh
generate-env.sh
lib.sh
logs.sh
restart.sh
up.sh
README.md
bundle-name:
description: Bundle archive filename.
required: false
default: bundle.zip
default: flightdeck.zip
release-tag:
description: Release tag to upload the bundle asset to.
required: true
token:
description: Token with permission to upload release assets.
required: true
outputs:
bundle-path:
description: Path to the built bundle archive.
Expand DownExpand Up@@ -40,3 +58,10 @@ runs:
exit 1
fi
echo "bundle-path=.bundle/$BUNDLE_NAME" >> "$GITHUB_OUTPUT"
- name: Upload bundle
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
BUNDLE_PATH: ${{ steps.build.outputs.bundle-path }}
run: gh release upload "$RELEASE_TAG" "$BUNDLE_PATH" --clobber
39 changes: 0 additions & 39 deletions .github/actions/discover-manifest-matrix/README.md

This file was deleted.

26 changes: 0 additions & 26 deletions .github/actions/discover-manifest-matrix/action.yml

This file was deleted.

43 changes: 43 additions & 0 deletions .github/actions/encrypt-env/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
# encrypt-env

Composite GitHub Action that renders an encryption config from GitHub Secrets/Variables, encrypts it for named age recipients, and uploads `.sops.env` to an existing GitHub Release.

The release must exist before this action runs.

## Usage

```yaml
- uses: rubykatzen/flightdeck/.github/actions/encrypt-env@main
with:
manifest: vaults/mainframe.yml # required
keys-directory: keys # default: keys
release-tag: ${{ needs.release.outputs.tag }} # required, must already exist
release-repo: "" # default: current repository
token: ${{ secrets.GITHUB_TOKEN }} # required
env:
GITHUB_SECRETS_JSON: ${{ toJson(secrets) }}
GITHUB_VARS_JSON: ${{ toJson(vars) }}
```

The calling job requires:

```yaml
permissions:
contents: write
```

## Manifest

```yaml
asset: mainframe.sops.env
keys:
- mainframe
apps:
- traefik
- rybbit
env:
APPS_DOMAIN: APPS_DOMAIN # output name: GitHub Secret/Variable name
APPS_TIMEZONE: APPS_TIMEZONE
```

The action renders `apps` as the comma-separated `APPS` dotenv value. For each name in `keys`, it loads `<keys-directory>/<name>.pub`. Secrets take precedence over Variables when both contain the same source key. Every source key must resolve or the action fails.
108 changes: 108 additions & 0 deletions .github/actions/encrypt-env/action.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
name: Encrypt env
description: Render a target env, encrypt it with SOPS age recipients, and upload it to a GitHub Release.
inputs:
manifest:
description: Path to the YAML env manifest.
required: true
release-repo:
description: GitHub repository containing the release. Defaults to the current repository.
required: false
default: ""
release-tag:
description: Existing release tag to publish the asset to.
required: true
keys-directory:
description: Directory containing public age recipients named <key>.pub.
required: false
default: keys
token:
description: Token with permission to write releases.
required: true
outputs:
release-repo:
description: GitHub repository used for publication.
value: ${{ steps.publish.outputs.release_repo }}
tag:
description: Release tag used for publication.
value: ${{ steps.publish.outputs.tag }}
ref:
description: Release asset ref in owner/repo@tag:asset format.
value: ${{ steps.publish.outputs.ref }}
runs:
using: composite
steps:
- name: Install dependencies
shell: bash
env:
SOPS_VERSION: "3.13.1"
run: |
python3 -m pip install --disable-pip-version-check --requirement "$GITHUB_ACTION_PATH/requirements.txt"
mkdir -p "$RUNNER_TEMP/encrypt-env/bin"
curl --fail --location --silent --show-error \
"https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}/sops-v${SOPS_VERSION}.linux.amd64" \
--output "$RUNNER_TEMP/encrypt-env/bin/sops"
chmod +x "$RUNNER_TEMP/encrypt-env/bin/sops"
echo "$RUNNER_TEMP/encrypt-env/bin" >> "$GITHUB_PATH"
- name: Render env
id: render
shell: bash
env:
MANIFEST: ${{ inputs.manifest }}
run: |
mkdir -p "$RUNNER_TEMP/encrypt-env"
python3 "$GITHUB_ACTION_PATH/scripts/render-env.py" \
--manifest "$MANIFEST" \
--output "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Resolve release target
id: target
shell: bash
env:
INPUT_RELEASE_REPO: ${{ inputs.release-repo }}
DEFAULT_RELEASE_REPO: ${{ github.repository }}
ASSET_NAME: ${{ steps.render.outputs.asset }}
run: |
release_repo="${INPUT_RELEASE_REPO:-$DEFAULT_RELEASE_REPO}"
echo "release_repo=$release_repo" >> "$GITHUB_OUTPUT"
echo "asset_name=$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Encrypt env
shell: bash
env:
KEYS: ${{ steps.render.outputs.keys }}
KEYS_DIRECTORY: ${{ inputs.keys-directory }}
run: |
recipients=""
IFS=',' read -ra names <<< "$KEYS"
for name in "${names[@]}"; do
recipient="$(tr -d '[:space:]' < "$KEYS_DIRECTORY/${name}.pub")"
if [[ ! "$recipient" =~ ^age1 ]]; then
echo "Invalid age recipient: $KEYS_DIRECTORY/${name}.pub" >&2
exit 1
fi
recipients="${recipients:+$recipients,}$recipient"
done
SOPS_AGE_RECIPIENTS="$recipients" sops encrypt \
--input-type dotenv \
--output-type dotenv \
"$RUNNER_TEMP/encrypt-env/plain.env" \
> "$RUNNER_TEMP/encrypt-env/.sops.env"
rm "$RUNNER_TEMP/encrypt-env/plain.env"
- name: Upload encrypted env
id: publish
shell: bash
env:
RELEASE_REPO: ${{ steps.target.outputs.release_repo }}
TAG: ${{ inputs.release-tag }}
ASSET_NAME: ${{ steps.target.outputs.asset_name }}
GH_TOKEN: ${{ inputs.token }}
run: |
set -euo pipefail
asset_path="$RUNNER_TEMP/encrypt-env/$ASSET_NAME"
cp "$RUNNER_TEMP/encrypt-env/.sops.env" "$asset_path"
gh release upload "$TAG" "$asset_path" --repo "$RELEASE_REPO" --clobber
echo "release_repo=$RELEASE_REPO" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "ref=$RELEASE_REPO@$TAG:$ASSET_NAME" >> "$GITHUB_OUTPUT"
- name: Cleanup
if: always()
shell: bash
run: rm -rf "$RUNNER_TEMP/encrypt-env"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,9 +12,8 @@
ENV_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
SOURCE_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*(?:__[A-Z0-9_]+)*$")
KEY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
RELEASE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
RELEASE_TAG_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
RELEASE_ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")
APP_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
ASSET_RE = re.compile(r"^[A-Za-z0-9_.-]+\.sops\.env$")


class ManifestError(Exception):
Expand DownExpand Up@@ -59,32 +58,31 @@ def load_manifest(path):
raise ManifestError(f"{path} is not valid YAML: {exc}") from exc
if not isinstance(manifest, dict):
raise ManifestError(f"{path} must contain a YAML mapping")
if "package" in manifest:
raise ManifestError("package has been replaced by release-repo/release-tag GitHub Releases configuration")
release_repo = manifest.get("release_repo")
release_tag = manifest.get("release_tag")
release_asset = manifest.get("release_asset", f"{path.stem}.sops.env")
unknown = sorted(set(manifest) - {"asset", "keys", "apps", "env"})
if unknown:
raise ManifestError("manifest contains unknown keys: " + ", ".join(unknown))
asset = manifest.get("asset")
keys = manifest.get("keys")
apps = manifest.get("apps")
env = manifest.get("env")
if "raw_env" in manifest:
raise ManifestError("raw_env is no longer supported; store values as data, not shell syntax")
if release_repo is not None and (
not isinstance(release_repo, str) or not RELEASE_REPO_RE.fullmatch(release_repo)
):
raise ManifestError("release_repo must be in owner/repo format")
if release_tag is not None and (
not isinstance(release_tag, str) or not RELEASE_TAG_RE.fullmatch(release_tag)
):
raise ManifestError("release_tag must contain only letters, numbers, dots, underscores, or hyphens")
if not isinstance(release_asset, str) or not RELEASE_ASSET_RE.fullmatch(release_asset):
raise ManifestError("release_asset must be named like server.sops.env")
if not isinstance(asset, str) or not ASSET_RE.fullmatch(asset):
raise ManifestError("asset must be named like server.sops.env")
if not isinstance(keys, list) or not keys:
raise ManifestError("keys must be a non-empty list")
if not isinstance(apps, list) or not apps:
raise ManifestError("apps must be a non-empty list")
if not isinstance(env, dict) or not env:
raise ManifestError("env must be a non-empty mapping")
for key in keys:
if not isinstance(key, str) or not KEY_NAME_RE.fullmatch(key):
raise ManifestError(f"invalid key name: {key!r}")
for app in apps:
if not isinstance(app, str) or not APP_NAME_RE.fullmatch(app):
raise ManifestError(f"invalid app name: {app!r}")
if len(apps) != len(set(apps)):
raise ManifestError("apps contains duplicate app names")
if "APPS" in env:
raise ManifestError("APPS must be configured through apps")
for output_name, source_name in env.items():
if not isinstance(output_name, str) or not ENV_NAME_RE.fullmatch(output_name):
raise ManifestError(f"invalid output env name: {output_name!r}")
Expand DownExpand Up@@ -113,7 +111,7 @@ def resolve_value(source_name, secrets, variables):


def render_env(manifest, secrets, variables):
lines = []
lines = [f"APPS={','.join(manifest['apps'])}"]
missing = []
for output_name, source_name in manifest["env"].items():
value = resolve_value(source_name, secrets, variables)
Expand DownExpand Up@@ -148,9 +146,7 @@ def main(argv=None):
args.output.chmod(0o600)
write_github_outputs(
{
"release_repo": manifest.get("release_repo", ""),
"release_tag": manifest.get("release_tag", ""),
"release_asset": manifest.get("release_asset", f"{args.manifest.stem}.sops.env"),
"asset": manifest["asset"],
"keys": ",".join(manifest["keys"]),
}
)
Expand Down
Loading