Add the Gitea Actions configuration
The third port of the same two things. Gitea walks WORKFLOW_DIRS and stops at the first directory that exists, so .gitea/workflows/ replaces .github/workflows/ outright on a Gitea instance — which is the point, since deploy.yml there is built on repository_dispatch and an environment: and Gitea has neither. Four differences shape the files: - No environment:, so no scoped secret and no required reviewers. The file does not write the key at all rather than claim a protection that Gitea parses and ignores; what gates a deploy is write access to the repository. - No repository_dispatch. External callers post to the workflow dispatch API, which fills in the same form — and can return a run id, so unlike repository_dispatch the caller may follow the deploy it asked for. - runs-on takes a literal label only, so GitHub's vars.DEPLOY_RUNNER expression becomes one documented line to edit. - A job is itself a container with no Docker socket, so the secret scan runs the pinned gitleaks binary instead of the upstream image. Input validation carries over unchanged and matters more here: type: choice constrains the dispatch form, not the API. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1130f2f07e
commit
971034e630
@@ -0,0 +1,131 @@
|
||||
---
|
||||
# Diagnostics, for when a deploy job hangs instead of failing.
|
||||
#
|
||||
# Two jobs, deliberately separate:
|
||||
#
|
||||
# ping uses the real deploy key and asks Ansible to talk to every host. This
|
||||
# is the right first thing to run after setting the secret up, and the
|
||||
# right first thing to run when a deploy misbehaves. It changes nothing.
|
||||
# probe uses no credentials at all and answers a narrower question: where do
|
||||
# the packets stop? Run it when ping times out.
|
||||
#
|
||||
# The distinction that matters, and the reason `probe` exists:
|
||||
#
|
||||
# Permission denied (publickey) the key is wrong — you reached sshd
|
||||
# Connection refused you reached the host, nothing is listening
|
||||
# Connection timed out packets are being dropped in transit
|
||||
#
|
||||
# Only the third is a network problem, and no amount of re-pasting the key will
|
||||
# fix it. See docs/connectivity.md.
|
||||
name: connectivity
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
hosts:
|
||||
description: Space-separated hosts to probe (defaults to the inventory)
|
||||
required: false
|
||||
default: edge.example.com app.example.com
|
||||
|
||||
jobs:
|
||||
ping:
|
||||
name: ansible ping
|
||||
# A literal label, for the reason spelled out in deploy.yml. Keep the two
|
||||
# files in step: a diagnostic that runs somewhere other than the deploy is
|
||||
# diagnosing the wrong machine.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install the pinned toolchain
|
||||
run: |
|
||||
set -eu
|
||||
scripts/check.sh --print-specs | head -1 | xargs -d '\n' pip install --quiet
|
||||
- name: Load the deploy key
|
||||
env:
|
||||
SSH_KEY_BACKEND: ci
|
||||
SSH_PRIVATE_KEY_B64: ${{ secrets.SSH_PRIVATE_KEY_B64 }}
|
||||
run: |
|
||||
set -eu
|
||||
SSH_KEY_PATH="$(scripts/load-ssh-key.sh)"
|
||||
echo "SSH_KEY_PATH=$SSH_KEY_PATH" >> "$GITHUB_ENV"
|
||||
# Success looks like `SUCCESS => {"ping": "pong"}` for every host. That
|
||||
# proves the runner decoded the key, reached port 22, authenticated, and ran
|
||||
# Python on the far side — which is everything a deploy needs except the
|
||||
# playbook itself.
|
||||
- name: Ping every host in the inventory
|
||||
working-directory: ansible
|
||||
run: ansible all -m ansible.builtin.ping
|
||||
|
||||
probe:
|
||||
name: network probe
|
||||
runs-on: ubuntu-latest
|
||||
# A report, not a gate. Every section must run even when the thing it probes
|
||||
# is unreachable, which is the normal case when you are running this. The
|
||||
# steps also keep themselves green with `set +e`, so the report survives on an
|
||||
# instance that ignores this key.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Probe
|
||||
env:
|
||||
HOSTS: ${{ github.event.inputs.hosts }}
|
||||
run: |
|
||||
set +e
|
||||
|
||||
# A bare TCP connect can be answered by a transparent proxy that then
|
||||
# says nothing, which looks like success and is not. Real sshd greets
|
||||
# first, so the banner is the only honest proof you reached the server.
|
||||
ssh_banner() {
|
||||
b=$(timeout 6 bash -c "exec 3<>/dev/tcp/$1/${2:-22}; head -c 12 <&3" 2>/dev/null || true)
|
||||
case "$b" in
|
||||
SSH-*) printf ' %-26s %-5s OK, sshd replied: %s\n' "$1" "${2:-22}" "$b" ;;
|
||||
'') printf ' %-26s %-5s NO REPLY (dropped, or a silent proxy)\n' "$1" "${2:-22}" ;;
|
||||
*) printf ' %-26s %-5s answered, but not sshd: %s\n' "$1" "${2:-22}" "$b" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Distinguishes "refused" (allowed out, nothing listening) from
|
||||
# "filtered" (dropped in transit). This is the whole point of the job:
|
||||
# if every port but 443 is filtered, you are behind an egress allowlist
|
||||
# and no alternative SSH port will help.
|
||||
port_state() {
|
||||
# `rc=0; out=$(...) || rc=$?` and not `out=$(...); rc=$?` — the latter
|
||||
# is a failing simple command, which under set -e ends the step before
|
||||
# rc is ever read, so a filtered port aborts the report instead of
|
||||
# describing it.
|
||||
rc=0
|
||||
out=$(timeout 5 bash -c "cat < /dev/null > /dev/tcp/$1/$2" 2>&1) || rc=$?
|
||||
if [ "$rc" -eq 0 ]; then s='open'
|
||||
elif printf '%s' "$out" | grep -qi refused; then
|
||||
s='refused -> port allowed out'
|
||||
elif [ "$rc" -eq 124 ]; then
|
||||
s='filtered -> blocked in transit'
|
||||
else
|
||||
s="unclear (rc=$rc) $out"
|
||||
fi
|
||||
printf ' %-26s %-5s %s\n' "$1" "$2" "$s"
|
||||
}
|
||||
|
||||
printf '== 0. does this runner have a network at all? ==\n'
|
||||
printf ' DNS: '; getent hosts github.com >/dev/null 2>&1 && echo resolves || echo 'CANNOT RESOLVE'
|
||||
# Egress to github.com is worth knowing for a second reason here: unless
|
||||
# your administrator set DEFAULT_ACTIONS_URL to `self`, it is also where
|
||||
# every `uses:` step is downloaded from.
|
||||
printf ' https external: '
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 \
|
||||
https://github.com || echo 'NO EGRESS'
|
||||
printf ' egress address: '; curl -s --max-time 10 https://ifconfig.me || echo unknown
|
||||
|
||||
printf '\n\n== 1. SSH to the target hosts ==\n'
|
||||
for h in $HOSTS; do ssh_banner "$h"; done
|
||||
|
||||
printf '\n== 2. SSH elsewhere: is outbound 22 blocked wholesale? ==\n'
|
||||
ssh_banner github.com
|
||||
|
||||
printf '\n== 3. Which ports are allowed out at all? ==\n'
|
||||
for h in $HOSTS; do
|
||||
port_state "$h" 22
|
||||
port_state "$h" 443
|
||||
done
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
# Deploys, on Gitea. One way in, and only one:
|
||||
#
|
||||
# - a human fills in the form under Actions > deploy > Run workflow, or the
|
||||
# same form is filled in over the API by another repository's job
|
||||
#
|
||||
# Nothing deploys on a push. That is the single most important property of this
|
||||
# file: a merge to the default branch changes what *would* be deployed, and a
|
||||
# person still decides when.
|
||||
#
|
||||
# Gitea has no `repository_dispatch`, so the two entry points GitHub gets collapse
|
||||
# into one: the workflow dispatch API posts to *this* form.
|
||||
#
|
||||
# POST /api/v1/repos/<owner>/<repo>/actions/workflows/deploy.yml/dispatches
|
||||
#
|
||||
# That is better than it sounds — the response can carry the run id, so a caller
|
||||
# can follow the deploy it asked for, which `repository_dispatch` never allowed.
|
||||
# See docs/triggering-deploys.md.
|
||||
#
|
||||
# The `options:` list has to be kept in step with ansible/playbooks/ by hand; a
|
||||
# choice input cannot be populated dynamically on any platform. It is step 4 of
|
||||
# skills/adding-a-stack/SKILL.md. Setup: docs/ci-gitea.md.
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stack:
|
||||
description: Which stack to act on
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- site
|
||||
- reverse-proxy
|
||||
- banner
|
||||
- metrics
|
||||
- static-site
|
||||
- webapp
|
||||
- webapp-staging
|
||||
mode:
|
||||
description: check reports what would change; deploy does it
|
||||
required: true
|
||||
default: check
|
||||
type: choice
|
||||
options:
|
||||
- check
|
||||
- deploy
|
||||
pull:
|
||||
description: >-
|
||||
Image pull policy. always fetches newer images and enables per-stack
|
||||
update work (backups, migrations).
|
||||
required: true
|
||||
default: policy
|
||||
type: choice
|
||||
options:
|
||||
- policy
|
||||
- always
|
||||
- missing
|
||||
- never
|
||||
|
||||
# Two deploys of the same stack at once would race on the host. Concurrency
|
||||
# syntax landed in Gitea 1.26; before that this block is ignored, and the only
|
||||
# thing standing between you and a race is that both runs are started by hand.
|
||||
concurrency:
|
||||
group: deploy-${{ github.event.inputs.stack }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ansible:
|
||||
name: ${{ github.event.inputs.mode || 'check' }} ${{ github.event.inputs.stack }}
|
||||
# Change this label if the runner that picks up `ubuntu-latest` cannot reach
|
||||
# your hosts, and register a second runner carrying whatever you put here.
|
||||
# It has to be a literal: Gitea supports `runs-on: xyz` and `runs-on: [xyz]`
|
||||
# only, so GitHub's `${{ vars.DEPLOY_RUNNER || 'ubuntu-latest' }}` trick has
|
||||
# no equivalent. Do not name a label no runner carries — the job then queues
|
||||
# forever rather than failing.
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment:` here. Gitea parses and ignores it, so writing one would
|
||||
# claim a protection that does not exist: there are no required reviewers and
|
||||
# no environment-scoped secrets. SSH_PRIVATE_KEY_B64 is an ordinary
|
||||
# repository (or organisation) secret, and what actually gates a deploy is
|
||||
# that dispatching a workflow needs write access to this repository. See
|
||||
# "What replaces environments" in docs/ci-gitea.md.
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# STACK, MODE and PULL reach a shell command, and over the dispatch API they
|
||||
# arrive from another repository. They go through the environment, never
|
||||
# through ${{ }} interpolation into a run: block — an expression is
|
||||
# substituted into the script *before* the shell sees it, so a crafted value
|
||||
# would be executed rather than compared. Validate, then use.
|
||||
#
|
||||
# `type: choice` is a UI constraint, not an API one. Do not read the
|
||||
# dropdown above as validation.
|
||||
- name: Validate the requested stack and pull policy
|
||||
env:
|
||||
# The `|| default` halves are not decoration. An input's declared
|
||||
# `default:` is applied by the dispatch *form*; a caller posting to the
|
||||
# API can leave the key out altogether, and a missing `mode` would then
|
||||
# be refused below rather than falling back to the safe value. `stack`
|
||||
# has no default on purpose — there is no safe guess for it.
|
||||
STACK: ${{ github.event.inputs.stack }}
|
||||
MODE: ${{ github.event.inputs.mode || 'check' }}
|
||||
PULL: ${{ github.event.inputs.pull || 'policy' }}
|
||||
run: |
|
||||
set -eu
|
||||
case "$STACK" in
|
||||
''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1 ;;
|
||||
esac
|
||||
case "$MODE" in
|
||||
check|deploy) ;;
|
||||
*) echo "refusing MODE='$MODE'"; exit 1 ;;
|
||||
esac
|
||||
# An unchecked value can smuggle extra `-e` overrides onto the command
|
||||
# line (stack_dest, ansible_host) and redirect the deploy somewhere else
|
||||
# entirely.
|
||||
case "$PULL" in
|
||||
policy|always|missing|never) ;;
|
||||
*) echo "refusing PULL='$PULL'"; exit 1 ;;
|
||||
esac
|
||||
test -f "ansible/playbooks/${STACK}.yml" \
|
||||
|| { echo "no playbook for '$STACK'"; exit 1; }
|
||||
{
|
||||
echo "STACK=$STACK"
|
||||
echo "MODE=$MODE"
|
||||
echo "PULL=$PULL"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install the pinned toolchain
|
||||
run: |
|
||||
set -eu
|
||||
scripts/check.sh --print-specs | head -1 | xargs -d '\n' pip install --quiet
|
||||
ansible-galaxy install -r ansible/requirements.yml
|
||||
|
||||
# Two lines, not `export SSH_KEY_PATH="$(...)"`. export is a special builtin,
|
||||
# so the compound command reports *its* exit status and swallows the
|
||||
# script's — under `set -e` a loader failure goes unnoticed and the job dies
|
||||
# much later with a per-host "Permission denied (publickey)" instead of the
|
||||
# loader's own message saying which variable was missing.
|
||||
- name: Load the deploy key
|
||||
env:
|
||||
SSH_KEY_BACKEND: ci
|
||||
SSH_PRIVATE_KEY_B64: ${{ secrets.SSH_PRIVATE_KEY_B64 }}
|
||||
run: |
|
||||
set -eu
|
||||
SSH_KEY_PATH="$(scripts/load-ssh-key.sh)"
|
||||
echo "SSH_KEY_PATH=$SSH_KEY_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run the playbook
|
||||
working-directory: ansible
|
||||
env:
|
||||
ANSIBLE_FORCE_COLOR: "1"
|
||||
run: |
|
||||
set -eu
|
||||
# An `if`, not `[ ... ] && extra=...`: when the test fails the AND-list
|
||||
# returns non-zero, and under `set -e` that ends the job right here with
|
||||
# no error message and a green-looking cancel.
|
||||
if [ "$MODE" = "check" ]; then
|
||||
set -- --check --diff
|
||||
else
|
||||
set --
|
||||
fi
|
||||
ansible-playbook "playbooks/${STACK}.yml" "$@" -e "pull=$PULL"
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
# The static gate on Gitea. The same three checks as .github/workflows/lint.yml,
|
||||
# with the adjustments a Gitea runner forces. Setup: docs/ci-gitea.md.
|
||||
#
|
||||
# Creating .gitea/workflows/ at all is a decision rather than a convenience.
|
||||
# Gitea walks WORKFLOW_DIRS in order — `.gitea/workflows` then `.github/workflows`
|
||||
# by default — and stops at the first directory that exists. So this directory
|
||||
# replaces the GitHub workflows wholesale on a Gitea instance, which is what you
|
||||
# want: deploy.yml there is built on `repository_dispatch` and an `environment:`,
|
||||
# and Gitea has neither.
|
||||
#
|
||||
# Delete whichever platform you are not using rather than letting it rot.
|
||||
name: lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
# Concurrency syntax landed in Gitea 1.26. Older instances ignore this block
|
||||
# rather than failing on it — you simply get both runs.
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ansible:
|
||||
name: yamllint + ansible-lint + syntax
|
||||
# A literal label, never `${{ vars.SOMETHING }}`: Gitea supports `runs-on: xyz`
|
||||
# and `runs-on: [xyz]` and nothing more. There are also no hosted runners, so
|
||||
# this label is only ever your own — see "Runners" in docs/ci-gitea.md.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Resolved against DEFAULT_ACTIONS_URL, which is github.com unless your
|
||||
# administrator set it to `self`. On an instance that cannot reach github.com
|
||||
# this step is the first thing that fails, and it fails for every workflow —
|
||||
# mirror actions/checkout into the instance, or set the setting back.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Keep this even though the runner image ships a Python. Ubuntu 24.04 images
|
||||
# are PEP 668 "externally managed", where a plain `pip install` refuses to
|
||||
# touch the system interpreter; setup-python brings its own and sidesteps it.
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# scripts/check.sh is the single source of the version pins; it prints them
|
||||
# so that this workflow installs exactly what a contributor runs locally.
|
||||
# Nothing here can drift from `make check`.
|
||||
- name: Install the pinned toolchain
|
||||
run: |
|
||||
set -eu
|
||||
scripts/check.sh --print-specs | tee /dev/stderr | xargs -d '\n' pip install --quiet
|
||||
|
||||
- name: Run the static gate
|
||||
run: RUNNER=installed scripts/check.sh
|
||||
|
||||
shell:
|
||||
name: shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Jobs run as root inside the runner's container, where `sudo` is often not
|
||||
# installed at all. Asking for it only when the uid is not 0 keeps this step
|
||||
# working on a container runner and on a `:host` runner alike.
|
||||
- name: Install shellcheck
|
||||
run: |
|
||||
set -eu
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
sudo=""
|
||||
[ "$(id -u)" -eq 0 ] || sudo=sudo
|
||||
$sudo apt-get update -qq
|
||||
$sudo apt-get install -y --no-install-recommends shellcheck
|
||||
fi
|
||||
- name: Check the shell scripts
|
||||
run: shellcheck scripts/*.sh server/*/*/app/*.sh
|
||||
|
||||
secrets:
|
||||
name: secret scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# detect scans history, not just the working tree. A secret that was
|
||||
# committed and then removed is still a leaked secret.
|
||||
fetch-depth: 0
|
||||
|
||||
# The GitHub workflow runs the upstream gitleaks *image*. That is not
|
||||
# portable here: an act_runner job is itself a container, and it has no
|
||||
# Docker socket unless the administrator mounted one into it, so
|
||||
# `docker run` fails with "Cannot connect to the Docker daemon". The
|
||||
# released binary needs nothing but curl and has the same behaviour.
|
||||
#
|
||||
# Pinned deliberately: a scanner that changes under you turns a green
|
||||
# history red on a morning you changed nothing.
|
||||
- name: Fetch the pinned gitleaks binary
|
||||
env:
|
||||
GITLEAKS_VERSION: 8.30.1
|
||||
run: |
|
||||
set -eu
|
||||
base=https://github.com/gitleaks/gitleaks/releases/download
|
||||
curl -sSfL -o /tmp/gitleaks.tar.gz \
|
||||
"$base/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
||||
# Left in /tmp and run from there. Installing into /usr/local/bin would
|
||||
# need root, which a `:host` runner does not have and should not want.
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||
chmod +x /tmp/gitleaks
|
||||
|
||||
- name: Scan the working tree and history for committed secrets
|
||||
run: |
|
||||
/tmp/gitleaks detect --source=. --config=.gitleaks.toml \
|
||||
--redact --verbose --no-banner
|
||||
Reference in New Issue
Block a user