Merge pull request 'Fix the review findings of the VPN actions, lint them in CI, record MIL-012 results' (#195) from mil-012-review-fixes-and-docs into main
Reviewed-on: #195
This commit was merged in pull request #195.
This commit is contained in:
@@ -5,3 +5,8 @@ root = true
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
|
||||
[*.sh]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
switch_case_indent = true
|
||||
|
||||
@@ -26,3 +26,9 @@ jobs:
|
||||
|
||||
- name: Check the coverage of the tools
|
||||
run: .venv/bin/python -m coverage report
|
||||
|
||||
- name: Lint the shell scripts of the actions
|
||||
env:
|
||||
SHELLCHECK: .venv/bin/shellcheck
|
||||
SHFMT: .venv/bin/shfmt
|
||||
run: bash tools/lint-action-scripts.sh actions
|
||||
|
||||
@@ -28,9 +28,13 @@ inputs:
|
||||
required: false
|
||||
default: ""
|
||||
version:
|
||||
description: "Tailscale version to install when tailscale is not on the runner, e.g. 1.80.2. Pin it; latest is allowed"
|
||||
description: "Exact Tailscale version to install when tailscale is not on the runner, e.g. 1.80.2. Empty uses the installed tailscale and fails when there is none"
|
||||
required: false
|
||||
default: "latest"
|
||||
default: ""
|
||||
sha256:
|
||||
description: "Optional SHA-256 of the downloaded archive tailscale_<version>_<arch>.tgz; the install fails when it differs"
|
||||
required: false
|
||||
default: ""
|
||||
userspace-networking:
|
||||
description: >-
|
||||
true runs tailscaled without a TUN device (no NET_ADMIN needed); tailnet
|
||||
@@ -70,6 +74,7 @@ runs:
|
||||
TS_TAGS: ${{ inputs.tags }}
|
||||
TS_HOSTNAME: ${{ inputs.hostname }}
|
||||
TS_VERSION: ${{ inputs.version }}
|
||||
TS_SHA256: ${{ inputs.sha256 }}
|
||||
TS_USERSPACE: ${{ inputs.userspace-networking }}
|
||||
TS_ARGS: ${{ inputs.args }}
|
||||
TS_TIMEOUT: ${{ inputs.timeout }}
|
||||
|
||||
@@ -7,14 +7,24 @@
|
||||
#
|
||||
# Settings come from the environment, never from interpolated expressions:
|
||||
# TS_AUTHKEY, TS_OAUTH_SECRET, TS_TAGS, TS_HOSTNAME, TS_VERSION, TS_USERSPACE,
|
||||
# TS_ARGS, TS_TIMEOUT, TS_HEALTH_CHECK_HOST. TS_TAILSCALE, TS_TAILSCALED,
|
||||
# TS_SUDO and TS_STATE_DIR name the commands and the work folder, for the tests.
|
||||
# TS_SHA256, TS_ARGS, TS_TIMEOUT, TS_HEALTH_CHECK_HOST. TS_TAILSCALE,
|
||||
# TS_TAILSCALED, TS_SUDO, TS_STATE_DIR, TS_START_WAIT (tries) and
|
||||
# TS_WAIT_INTERVAL (seconds) name the commands, the work folder and the waits,
|
||||
# for the tests.
|
||||
# The auth key is passed to tailscale in a file of mode 600, never on a command
|
||||
# line. Every failure is printed as ::error::[category] message.
|
||||
|
||||
TS_TAILSCALE="${TS_TAILSCALE:-tailscale}"
|
||||
TS_TAILSCALED="${TS_TAILSCALED:-tailscaled}"
|
||||
SUDO="${TS_SUDO-sudo}"
|
||||
# sudo is used only when the job is not root: a job container runs as root and
|
||||
# often has no sudo. TS_SUDO overrides it (empty for none).
|
||||
if [ -n "${TS_SUDO+x}" ]; then
|
||||
SUDO="$TS_SUDO"
|
||||
elif [ "$(id -u)" = "0" ]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
fi
|
||||
STATE_DIR="${TS_STATE_DIR:-${RUNNER_TEMP:-/tmp}/tailscale}"
|
||||
|
||||
fail() {
|
||||
@@ -46,8 +56,10 @@ validate_inputs() {
|
||||
fi
|
||||
[[ "$TS_TIMEOUT" =~ ^[0-9]+$ ]] ||
|
||||
fail configuration "timeout must be a number of seconds."
|
||||
[[ "$TS_VERSION" =~ ^(latest|[0-9]+\.[0-9]+\.[0-9]+)$ ]] ||
|
||||
fail configuration "version must be latest or like 1.80.2."
|
||||
[[ "$TS_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)?$ ]] ||
|
||||
fail configuration "version must be an exact version like 1.80.2."
|
||||
[[ "${TS_SHA256:-}" =~ ^([0-9a-fA-F]{64})?$ ]] ||
|
||||
fail configuration "sha256 must be 64 hexadecimal digits."
|
||||
case "$TS_USERSPACE" in
|
||||
true | false) ;;
|
||||
*) fail configuration "userspace-networking must be true or false." ;;
|
||||
@@ -66,6 +78,8 @@ machine_arch() {
|
||||
|
||||
install_tools() {
|
||||
command -v "$TS_TAILSCALE" >/dev/null 2>&1 && command -v "$TS_TAILSCALED" >/dev/null 2>&1 && return 0
|
||||
[ -n "$TS_VERSION" ] ||
|
||||
fail configuration "tailscale is not on the runner; set version (for example 1.80.2) so it can be installed."
|
||||
local arch url tmp
|
||||
arch="$(machine_arch)"
|
||||
url="https://pkgs.tailscale.com/stable/tailscale_${TS_VERSION}_${arch}.tgz"
|
||||
@@ -73,6 +87,10 @@ install_tools() {
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL "$url" -o "$tmp/tailscale.tgz" ||
|
||||
fail environment "could not download $url."
|
||||
if [ -n "${TS_SHA256:-}" ]; then
|
||||
echo "${TS_SHA256} $tmp/tailscale.tgz" | sha256sum -c --quiet - ||
|
||||
fail environment "the downloaded archive does not match sha256."
|
||||
fi
|
||||
tar -xzf "$tmp/tailscale.tgz" -C "$tmp" --strip-components=1 ||
|
||||
fail environment "could not unpack the Tailscale archive."
|
||||
$SUDO install -m 755 "$tmp/tailscale" "$tmp/tailscaled" /usr/local/bin/ ||
|
||||
@@ -80,13 +98,34 @@ install_tools() {
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
daemon_answers() {
|
||||
"$TS_TAILSCALE" version --daemon >/dev/null 2>&1
|
||||
}
|
||||
|
||||
start_daemon() {
|
||||
# a daemon that already runs belongs to the host; this action would log it out
|
||||
if daemon_answers; then
|
||||
fail environment "a tailscaled already runs on this runner; the action starts its own and must not take over the host's. Use it on a job container."
|
||||
fi
|
||||
local args=(--state=mem:)
|
||||
if [ "$TS_USERSPACE" = "true" ]; then
|
||||
args+=(--tun=userspace-networking --socks5-server=localhost:1055)
|
||||
fi
|
||||
mkdir -p "$STATE_DIR"
|
||||
$SUDO nohup "$TS_TAILSCALED" "${args[@]}" >"$STATE_DIR/tailscaled.log" 2>&1 &
|
||||
: >"$STATE_DIR/started-by-action"
|
||||
wait_for_daemon
|
||||
}
|
||||
|
||||
# wait_for_daemon: tailscaled needs a moment to create its socket
|
||||
wait_for_daemon() {
|
||||
local tries="${TS_START_WAIT:-30}" i
|
||||
for ((i = 0; i < tries; i++)); do
|
||||
daemon_answers && return 0
|
||||
sleep "${TS_WAIT_INTERVAL:-1}"
|
||||
done
|
||||
fail connection "tailscaled did not answer in $tries tries; the last lines of its log: $(tail -n 5 "$STATE_DIR/tailscaled.log" 2>/dev/null | tr '
|
||||
' ' ')"
|
||||
}
|
||||
|
||||
# write_key_file: the auth key in a file of mode 600; prints its path
|
||||
@@ -127,6 +166,11 @@ tailnet_up() {
|
||||
}
|
||||
|
||||
tailnet_down() {
|
||||
# only a daemon this action started is logged out and stopped
|
||||
if [ ! -f "$STATE_DIR/started-by-action" ]; then
|
||||
echo "This action did not start tailscaled on this runner; nothing to do."
|
||||
return 0
|
||||
fi
|
||||
$SUDO "$TS_TAILSCALE" logout 2>/dev/null || echo "The node was not logged in."
|
||||
$SUDO "$TS_TAILSCALE" down 2>/dev/null || true
|
||||
$SUDO pkill -x "$(basename "$TS_TAILSCALED")" 2>/dev/null || true
|
||||
@@ -138,7 +182,7 @@ main() {
|
||||
set -uo pipefail
|
||||
TS_USERSPACE="${TS_USERSPACE:-false}"
|
||||
TS_TIMEOUT="${TS_TIMEOUT:-60}"
|
||||
TS_VERSION="${TS_VERSION:-latest}"
|
||||
TS_VERSION="${TS_VERSION:-}"
|
||||
case "${1:-}" in
|
||||
mask) mask_secrets ;;
|
||||
up) tailnet_up ;;
|
||||
|
||||
@@ -15,7 +15,17 @@
|
||||
WG_DIR="${WG_DIR:-/etc/wireguard}"
|
||||
WG_QUICK="${WG_QUICK:-wg-quick}"
|
||||
WG_PING="${WG_PING:-ping}"
|
||||
SUDO="${WG_SUDO-sudo}"
|
||||
# sudo is used only when the job is not root: a job container runs as root and
|
||||
# often has no sudo. WG_SUDO overrides it (empty for none).
|
||||
if [ -n "${WG_SUDO+x}" ]; then
|
||||
SUDO="$WG_SUDO"
|
||||
elif [ "$(id -u)" = "0" ]; then
|
||||
SUDO=""
|
||||
else
|
||||
SUDO="sudo"
|
||||
fi
|
||||
# variables that must reach wg-quick through sudo, which resets the environment
|
||||
WG_QUICK_ENV=()
|
||||
|
||||
fail() {
|
||||
echo "::error::[$1] $2"
|
||||
@@ -23,14 +33,24 @@ fail() {
|
||||
}
|
||||
|
||||
mask_secrets() {
|
||||
local value line
|
||||
local value line key
|
||||
for value in "${WG_PRIVATE_KEY:-}" "${WG_PRESHARED_KEY:-}"; do
|
||||
[ -n "$value" ] && echo "::add-mask::$value"
|
||||
done
|
||||
# every line of a full configuration is masked, so a key cannot leak in pieces
|
||||
# a full configuration: mask the value of every PrivateKey and PresharedKey
|
||||
# line, and nothing else, so the rest of the log stays readable
|
||||
while IFS= read -r line; do
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
[ -n "$line" ] && echo "::add-mask::$line"
|
||||
line="${line//$'␍'/}"
|
||||
key="${line%%=*}"
|
||||
key="${key//[[:space:]]/}"
|
||||
case "${key,,}" in
|
||||
privatekey | presharedkey)
|
||||
value="${line#*=}"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
[ -n "$value" ] && echo "::add-mask::$value"
|
||||
;;
|
||||
esac
|
||||
done <<<"${WG_CONFIG:-}"
|
||||
return 0
|
||||
}
|
||||
@@ -51,6 +71,11 @@ render_config() {
|
||||
[ -n "${!name:-}" ] ||
|
||||
fail configuration "set config, or all of private-key, address, peer-public-key, endpoint and allowed-ips ($name is empty)."
|
||||
done
|
||||
for name in WG_PRIVATE_KEY WG_ADDRESS WG_PEER_PUBLIC_KEY WG_ENDPOINT WG_ALLOWED_IPS WG_PRESHARED_KEY WG_DNS; do
|
||||
# a line break would add directives (PostUp runs as root) to the file
|
||||
[[ "${!name:-}" != *[[:cntrl:]]* ]] ||
|
||||
fail configuration "$name must be one line without control characters."
|
||||
done
|
||||
[[ "$WG_KEEPALIVE" =~ ^[0-9]+$ ]] ||
|
||||
fail configuration "persistent-keepalive must be a number of seconds."
|
||||
printf '[Interface]\nPrivateKey = %s\nAddress = %s\n' "$WG_PRIVATE_KEY" "$WG_ADDRESS"
|
||||
@@ -77,7 +102,7 @@ choose_implementation() {
|
||||
return 0
|
||||
fi
|
||||
if command -v wireguard-go >/dev/null 2>&1 || $SUDO apt-get install -y -qq wireguard-go 2>/dev/null; then
|
||||
export WG_QUICK_USERSPACE_IMPLEMENTATION=wireguard-go
|
||||
WG_QUICK_ENV=(WG_QUICK_USERSPACE_IMPLEMENTATION=wireguard-go)
|
||||
echo "No kernel WireGuard module; using wireguard-go."
|
||||
else
|
||||
fail environment "the runner has no WireGuard kernel module and wireguard-go could not be installed; the runner container needs NET_ADMIN and /dev/net/tun (see guides/vpn-wireguard.md)."
|
||||
@@ -97,8 +122,12 @@ tunnel_up() {
|
||||
$SUDO install -d -m 700 "$WG_DIR"
|
||||
(umask 077 && printf '%s\n' "$rendered" | $SUDO tee "$conf" >/dev/null)
|
||||
$SUDO chmod 600 "$conf"
|
||||
$SUDO "$WG_QUICK" up "$WG_INTERFACE" ||
|
||||
# env keeps the userspace variable through sudo; the file holds the key, so
|
||||
# it is removed when the interface does not come up
|
||||
if ! $SUDO env ${WG_QUICK_ENV[@]+"${WG_QUICK_ENV[@]}"} "$WG_QUICK" up "$WG_INTERFACE"; then
|
||||
$SUDO rm -f "$conf"
|
||||
fail connection "wg-quick could not bring $WG_INTERFACE up."
|
||||
fi
|
||||
if [ -n "${WG_HEALTH_CHECK_HOST:-}" ]; then
|
||||
"$WG_PING" -c 3 -W 5 "$WG_HEALTH_CHECK_HOST" >/dev/null 2>&1 ||
|
||||
fail connection "health-check-host '$WG_HEALTH_CHECK_HOST' does not answer through the tunnel."
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-0011: A VPN for deployment is delivered as a composite action
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | ADR-0011 |
|
||||
| CrossReference | [MIL-012], [ADR-0005], [ADR-0006] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-19 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [3325bea] |
|
||||
|
||||
Allowed Status values: `Proposed`, `Accepted`, `Rejected`, `Deprecated`, `Superseded by ADR-NNNN`.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
A deployment job often has to reach hosts that are not on the internet. [MIL-012] adds a way to bring a VPN up in the job, for WireGuard and for Tailscale. The GitHub marketplace has `tailscale/github-action` for Tailscale; it is written for GitHub Actions, has not been verified on Gitea Actions, and WireGuard has no such action. Every other shared automation of this repository is a reusable workflow ([ADR-0005], [ADR-0009], [ADR-0010]), so the question is whether a VPN is one too.
|
||||
|
||||
Options:
|
||||
|
||||
1. **A reusable workflow.** Consistent with the rest of the repository. But a called workflow runs as a separate job, on its own runner and network; a tunnel brought up there is gone when that job ends and is never seen by the caller's deployment job. It cannot work.
|
||||
2. **The marketplace action `tailscale/github-action`.** No code to maintain. Written for GitHub, not verified on Gitea, nothing for WireGuard, and an action from outside the instance that receives the VPN secret.
|
||||
3. **A composite action of this repository**, one for each VPN, in `actions/<name>/`, called as a step of the caller's own job with `uses:` and `gitea.action_path` for its script. It runs in the job, so the tunnel is there for the steps after it.
|
||||
|
||||
Facts: a composite action in a subfolder of this repository resolves on the instance in both the short form (`TirSystem/github-action/actions/<name>@<ref>`) and with the full address, and `gitea.action_path` points at its folder (verified in [MIL-012] task 1, runs 417 and 418 of the instance). A key must not reach a log or a command line. A tunnel needs a network interface, so the runner container needs `NET_ADMIN` and `/dev/net/tun`, which this repository cannot give.
|
||||
|
||||
## Decision
|
||||
|
||||
A VPN for deployment is delivered as **a composite action of this repository**, one per VPN: `actions/connect-wireguard` and `actions/connect-tailscale`.
|
||||
|
||||
- Each action has a script file next to its `action.yml` that does the work. The `run:` lines only call it, and every input reaches the script as an environment variable, never as an interpolated expression ([ADR-0005]).
|
||||
- Each action has an `up` and a `down` mode. The caller runs `down` in a last step with `if: always()`.
|
||||
- Secrets are masked first, written only to a file of mode 600 where a file is needed, never printed and never put on a command line.
|
||||
- The scripts are linted (shellcheck and shfmt) and tested offline with stub commands on every pull request.
|
||||
- The actions are versioned with the repository ([ADR-0006]); a new action is a minor release, and callers pin the tag.
|
||||
- The runner is outside the scope of the action: the operator (S03) provides a runner whose container has `NET_ADMIN` and `/dev/net/tun`. The actions state the requirement and fail with a message that names it.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The tunnel lives in the job that deploys, which a reusable workflow cannot do.
|
||||
- One implementation for Gitea, covered by offline tests; no GitHub-only action receives the VPN secret.
|
||||
- WireGuard and Tailscale share one contract (`up`, `down`, a health check, masked secrets, categorised errors).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- Two scripts to maintain, and behaviour that stubs cannot show (daemon start-up, `sudo`, the runner's capabilities) is verified only by runs on a real runner ([MIL-012] tasks 3 and 4).
|
||||
- A composite action cannot declare a post step, so the caller must remember the `down` step; a cancelled job can leave a node or a key file behind until the runner cleans up.
|
||||
- The action needs a privileged runner, which widens the attack surface of that runner; the guides tell the operator to keep it to a dedicated label.
|
||||
|
||||
## Affected Artifacts
|
||||
|
||||
- [MIL-012] — the decision behind its deliverable and its tasks 3 and 4.
|
||||
- [ADR-0005] — the same contract style (script file, environment variables, categorised failures) is used for the actions.
|
||||
- [ADR-0006] — a new action is a minor release.
|
||||
|
||||
---
|
||||
|
||||
[MIL-012]: ../milestones/mil-012-vpn-actions-for-deployment.md
|
||||
[ADR-0005]: ./adr-0005-quality-check-contract.md
|
||||
[ADR-0006]: ./adr-0006-release-process-and-version-pinning.md
|
||||
[ADR-0009]: ./adr-0009-wiki-publishing-reusable-workflow.md
|
||||
[ADR-0010]: ./adr-0010-code-metrics-badges-contract.md
|
||||
[3325bea]: https://git.tirsystem.com/TirSystem/github-action/commit/3325bea772304b54e8dfffad592b4afadc3ec2b8
|
||||
@@ -11,9 +11,9 @@ each document lives in *this* project and the next version to use.
|
||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||
| MIL | Milestone / Gateway | docs/milestones/*.md | 013 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 082 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 083 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0011 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0012 |
|
||||
| UCD | Use Case Diagram | docs/use-case-diagram.md | 002 |
|
||||
| US | User Story | docs/user-stories.md | 002 |
|
||||
| UC | Use Case | docs/uc-NNN/uc.md | 009 |
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-012 |
|
||||
| CrossReference | [BC-001], [US-001], [MIL-004], [MIL-011], [ADR-0006] |
|
||||
| CrossReference | [BC-001], [US-001], [MIL-004], [MIL-011], [ADR-0006], [ADR-0011] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-19 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version; reviewed (RC-080), accepted by the author in chat as stand-in for S01 with target date 2026-11-06 | [c5d5941] |
|
||||
| 2026-10-19 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version; reviewed (RC-080), accepted by the author in chat as stand-in for S01 with target date 2026-11-06 | [c5d5941] |
|
||||
| 2026-10-19 | Accepted | Jens Tirsvad Nielsen | S01 | Added the results of tasks 1, 6 and 9 and the fixes from the review of the first pull request; reviewed (RC-082), accepted by the author in chat as stand-in for S01 | [3325bea] |
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +87,28 @@ Release `2.2.0` of this repository with two composite actions, `actions/connect-
|
||||
| 11 | Pin the framework templates | In `TirSystem/sqa-qc-framework`, change `@wireguard-vpn-action` to `@v2` in the two templates and the branch guide, remove the "experimental" notes that the release resolves, and open the pull request after release 2.2.0. This is a change in another repository, in its own pull request. | No | |
|
||||
| 12 | Review | Record `RC-*` for this milestone, the actions with their tests, and the guides, each with a verdict. | No | |
|
||||
|
||||
|
||||
## Verification results
|
||||
|
||||
Task 1 was run on 2026-10-19 with the throwaway branch `verify-subfolder-action`, which calls both actions in the short form (`TirSystem/github-action/actions/<name>@<ref>`) and with the full address (`https://git.tirsystem.com/TirSystem/github-action/actions/<name>@<ref>`), with every input empty, so that a resolved action fails with its own `[configuration]` message.
|
||||
|
||||
| Call | Result | Evidence |
|
||||
| --- | --- | --- |
|
||||
| Either form at `@wireguard-vpn-action` | Fails before the step: `Unable to resolve wireguard-vpn-action: reference not found`. The branch had been deleted when its pull request merged, so the ref did not exist; this is not a defect of the form. | run 417 (`2424`) |
|
||||
| Short form at `@main`, both actions | Resolves and runs: the log shows `Run Pre ...@main`, the action's `mask` step and its main step, which fails with `[configuration] set config, or all of private-key, ... (WG_PRIVATE_KEY is empty)` and `[configuration] give authkey or oauth-secret` | run 418 (`2425`), jobs `wg-short`, `ts-short` |
|
||||
| Full address at `@main`, both actions | The same result as the short form | run 418, jobs `wg-full`, `ts-full` |
|
||||
|
||||
Both forms resolve. The short form is the one the guides show; the full address stays in the guides as the fallback. `gitea.action_path` is set (the script path in the log is under `/var/run/act/actions/<id>/actions/<name>/`). A `uses:` of a branch that no longer exists fails with "reference not found", so a caller of an experimental ref breaks when its branch is deleted.
|
||||
|
||||
Tasks 6 and 9 were done on 2026-10-19 on the branch `mil-012-review-fixes-and-docs`:
|
||||
|
||||
- **Lint (task 6):** `tools/lint-action-scripts.sh` runs shellcheck and shfmt on `actions/**/*.sh`; `validate-workflows.yml` runs it with pinned tools (`shellcheck-py==0.10.0.1`, `shfmt-py==3.11.0.2`, from `tests/requirements.txt`), and `.editorconfig` sets the shell format (two spaces, indented case labels, as the shell conventions say). `tests/test_lint_action_scripts.py` seeds a shellcheck problem and a badly formatted script and shows both fail, and shows the scripts of the actions pass.
|
||||
- **Decision (task 9):** [ADR-0011] (Proposed). Objective O6 and the Project Plan entries were added at the acceptance of this milestone ([RC-080], [RC-081]).
|
||||
|
||||
The review of pull request 194 (ten findings) is answered in the same branch: `tailscaled` is waited for before `tailscale up`; the `wireguard-go` choice survives `sudo`; `sudo` is used only when the job is not root; a `tailscaled` that already runs on the runner is not taken over and `down` leaves a daemon it did not start; the Tailscale install needs an exact `version`, with an optional `sha256`; a configuration field with a line break is refused; only the key values are masked; the key file is removed when `wg-quick` fails; and the guides call `@main` until the release. These are offline-tested with stubs; tasks 3 and 4 still have to show them on a runner.
|
||||
|
||||
Tasks 2, 3, 4, 5, 7, 8, 10, 11 and 12 are open: they need the trial runner (task 2), which is the operator's, or follow the verified guides (tasks 7 and 8) or the release (tasks 10 and 11).
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
@@ -98,6 +121,9 @@ Release `2.2.0` of this repository with two composite actions, `actions/connect-
|
||||
[MIL-012]: ./mil-012-vpn-actions-for-deployment.md
|
||||
[RC-080]: ../sqa/reviews/rc-080-mil-012-plan.md
|
||||
[RC-081]: ../sqa/reviews/rc-081-bc-001-o6.md
|
||||
[RC-082]: ../sqa/reviews/rc-082-mil-012-results.md
|
||||
[ADR-0006]: ../adr/adr-0006-release-process-and-version-pinning.md
|
||||
[ADR-0011]: ../adr/adr-0011-vpn-composite-actions.md
|
||||
[PP-001]: ../project-plan.md
|
||||
[c5d5941]: https://git.tirsystem.com/TirSystem/github-action/commit/c5d59418698316866807388bf61dcb6166d9fa8b
|
||||
[3325bea]: https://git.tirsystem.com/TirSystem/github-action/commit/3325bea772304b54e8dfffad592b4afadc3ec2b8
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Review Record: MIL-012 results of tasks 1, 6 and the review fixes (MIL-012)
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-082 |
|
||||
| CrossReference | [MIL-012], [QC-MIL-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-19 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version; accepted by the author in chat as stand-in for S01 | [3325bea] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-012]
|
||||
- Checklist used: [QC-MIL-001]
|
||||
- Scope: delta re-review of the milestone after [RC-080]: the new `## Verification results` section (task 1, task 6, the fixes made after the review of pull request 194), the added `[ADR-0011]` in the CrossReference, and the claims about open tasks. Criteria 1 to 6 are re-read; none of the milestone's own text for them changed. [ADR-0011] is **not** reviewed here: it stays `Proposed` and needs a review against its own checklist
|
||||
- Language and domain: n/a (technical type)
|
||||
- Mode: supervised, not independent (the author, Jens Tirsvad Nielsen, asked in chat on 2026-10-19 for the acceptance and stands in for S01; the AI prepared the evidence)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Unchanged: release 2.2.0 with the two actions, guides, wiki pages in three languages, ADR, framework templates pinned. The results section does not claim any of it is delivered; it lists tasks 2 to 5, 7, 8 and 10 to 12 as open. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Unchanged, ten criteria. The results bear on two of them. Criterion 1 (a `uses:` of a subfolder action resolves, the form recorded): runs 417 and 418 show both forms resolve at `@main`, and the failure at the deleted branch is explained by the log text "reference not found". Criterion 6 (scripts linted in CI, a seeded error fails): met by `tools/lint-action-scripts.sh` and its tests on the working branch; the pull-request run of the new step is the final evidence. The other eight criteria are not claimed. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Unchanged; [ADR-0011] is added to the CrossReference and exists. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Unchanged: objective O6 and success criterion 6 of [BC-001], reviewed in [RC-081]. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Unchanged: owner S05, approving reviewer S01. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Unchanged: 2026-11-06. The open tasks 2 to 5 need the operator's runner (S03); the milestone states the dependency, and the date is at risk until the runner exists. |
|
||||
|
||||
## Results check
|
||||
|
||||
| # | Claim in the results | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Task 1: both forms resolve at `@main` | Pass | Run 418 (`.../actions/runs/2425`): jobs `wg-short`, `wg-full`, `ts-short`, `ts-full` each executed the action's `mask` step and main step and failed with the action's own `[configuration]` error. Read from the job logs through the API. |
|
||||
| 2 | Run 417 failed because the branch was deleted | Pass | `git ls-remote` showed no `wireguard-vpn-action` head after pull request 194 merged; the log says `Unable to resolve wireguard-vpn-action: reference not found`. |
|
||||
| 3 | Task 6: lint script, workflow step, pinned tools, seeded-error test | Pass | `tools/lint-action-scripts.sh`; the test file seeds a shellcheck problem and a badly formatted script, shows both fail, and shows the two action scripts pass; the full suite (590 tests) passes locally. Not yet run by the instance's CI. |
|
||||
| 4 | The ten findings of the review of pull request 194 are answered | Pass | Each has a code change and an offline test (the daemon wait with a delayed stub, `sudo` that resets the environment, a host daemon not taken over, a line break refused, the key file removed on failure, `latest` refused). The wording of the results says they are offline-tested with stubs and that tasks 3 and 4 must show them on a runner. |
|
||||
| 5 | Open tasks are listed honestly | Pass | Tasks 2, 3, 4, 5, 7, 8, 10, 11 and 12 are named as open, with the reason. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go - the results recorded are accurate and claim no more than the evidence shows. The author confirmed the acceptance in chat on 2026-10-19 as stand-in for S01; this is not an independent review.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Review [ADR-0011] against its checklist and accept or change it; it is `Proposed` | S01 | Before task 10 (release 2.2.0) |
|
||||
| Provide the `vpn` runner, the WireGuard peer and the Tailscale tag with an OAuth client for tasks 3 and 4; the target date 2026-11-06 depends on it | S03 | 2026-10-30 |
|
||||
| Confirm this record by S01 when an independent reading is possible | S01 | 2026-11-02 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-012]: ../../milestones/mil-012-vpn-actions-for-deployment.md
|
||||
[BC-001]: ../../business-case.md
|
||||
[ADR-0011]: ../../adr/adr-0011-vpn-composite-actions.md
|
||||
[RC-080]: ./rc-080-mil-012-plan.md
|
||||
[RC-081]: ./rc-081-bc-001-o6.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
|
||||
[3325bea]: https://git.tirsystem.com/TirSystem/github-action/commit/3325bea772304b54e8dfffad592b4afadc3ec2b8
|
||||
@@ -4,13 +4,13 @@
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | TM-001 |
|
||||
| CrossReference | [BC-001], [SA-001], [PP-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009], [MIL-010], [MIL-011], [MIL-012], [UC-008], [RC-001], [RC-002], [RC-003], [RC-004], [RC-005], [RC-006], [RC-007], [RC-008], [RC-009], [RC-010], [RC-011], [RC-012], [RC-013], [RC-014], [RC-015], [RC-016], [RC-017], [RC-018], [RC-019], [RC-020], [RC-021], [RC-022], [RC-023], [RC-024], [RC-025], [RC-026], [RC-027], [RC-028], [RC-029], [RC-030], [RC-031], [RC-032], [RC-033], [RC-034], [RC-035], [RC-036], [RC-037], [RC-038], [RC-039], [RC-040], [RC-041], [RC-042], [RC-043], [RC-044], [RC-045], [RC-046], [RC-047], [RC-048], [RC-049], [RC-050], [RC-051], [RC-052], [RC-053], [RC-054], [RC-055], [RC-056], [RC-057], [RC-058], [RC-059], [RC-060], [RC-061], [RC-062], [RC-063], [RC-064], [RC-065], [RC-066], [RC-067], [RC-068], [RC-069], [RC-070], [RC-071], [RC-072], [RC-073], [RC-074], [RC-075], [RC-076], [RC-077], [RC-078], [RC-079], [RC-080], [RC-081], [ADR-0007], [ADR-0008], [ADR-0009], [ADR-0010], [UC-007], [UC-006], [ADR-0006], [UC-005], [ADR-0005], [UCD-001], [US-001], [UC-001], [UC-002], [UC-003], [UC-004], [ADR-0001], [ADR-0002], [ADR-0003], [ADR-0004] |
|
||||
| CrossReference | [BC-001], [SA-001], [PP-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005], [MIL-006], [MIL-007], [MIL-008], [MIL-009], [MIL-010], [MIL-011], [MIL-012], [ADR-0011], [UC-008], [RC-001], [RC-002], [RC-003], [RC-004], [RC-005], [RC-006], [RC-007], [RC-008], [RC-009], [RC-010], [RC-011], [RC-012], [RC-013], [RC-014], [RC-015], [RC-016], [RC-017], [RC-018], [RC-019], [RC-020], [RC-021], [RC-022], [RC-023], [RC-024], [RC-025], [RC-026], [RC-027], [RC-028], [RC-029], [RC-030], [RC-031], [RC-032], [RC-033], [RC-034], [RC-035], [RC-036], [RC-037], [RC-038], [RC-039], [RC-040], [RC-041], [RC-042], [RC-043], [RC-044], [RC-045], [RC-046], [RC-047], [RC-048], [RC-049], [RC-050], [RC-051], [RC-052], [RC-053], [RC-054], [RC-055], [RC-056], [RC-057], [RC-058], [RC-059], [RC-060], [RC-061], [RC-062], [RC-063], [RC-064], [RC-065], [RC-066], [RC-067], [RC-068], [RC-069], [RC-070], [RC-071], [RC-072], [RC-073], [RC-074], [RC-075], [RC-076], [RC-077], [RC-078], [RC-079], [RC-080], [RC-081], [RC-082], [ADR-0007], [ADR-0008], [ADR-0009], [ADR-0010], [UC-007], [UC-006], [ADR-0006], [UC-005], [ADR-0005], [UCD-001], [US-001], [UC-001], [UC-002], [UC-003], [UC-004], [ADR-0001], [ADR-0002], [ADR-0003], [ADR-0004] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-14 | Proposed | Jens Tirsvad Nielsen | S01 | Added RC-079 (MIL-011 outcome, release 2.1.1) | [4aa3fd3] |
|
||||
| 2026-10-19 | Proposed | Jens Tirsvad Nielsen | S01 | Added MIL-012 (VPN actions for deployment), RC-080 and RC-081 (its plan and objective O6); CrossReference lists RC-076 to RC-079 | [c5d5941] |
|
||||
| 2026-10-19 | Proposed | Jens Tirsvad Nielsen | S01 | Added ADR-0011 and RC-082 (MIL-012 results of tasks 1, 6 and the review fixes) | [3325bea] |
|
||||
|
||||
---
|
||||
|
||||
@@ -32,6 +32,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| [MIL-003] | MIL | [BC-001], [PP-001], [MIL-002] | [MIL-004] | [RC-006], [RC-028], [RC-033], [RC-039] |
|
||||
| [MIL-004] | MIL | [BC-001], [PP-001], [MIL-003] | - | [RC-007], [RC-029], [RC-034], [RC-048] |
|
||||
| [ADR-0010] | ADR | [MIL-010], [UC-008], [US-001], [ADR-0005], [ADR-0006] | - | [RC-072], [RC-073] |
|
||||
| [ADR-0011] | ADR | [MIL-012], [ADR-0005], [ADR-0006] | - | - |
|
||||
| [RC-001] | RC | [BC-001] | - | - |
|
||||
| [RC-002] | RC | [PP-001] | - | - |
|
||||
| [RC-003] | RC | [MIL-001] | - | - |
|
||||
@@ -45,7 +46,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| [MIL-009] | MIL | [BC-001], [PP-001], [MIL-008], [ADR-0004], [ADR-0006], [ADR-0009] | - | [RC-064], [RC-066] |
|
||||
| [MIL-010] | MIL | [BC-001], [PP-001], [MIL-003], [MIL-004] | [MIL-011] | [RC-068], [RC-074], [RC-075] |
|
||||
| [MIL-011] | MIL | [BC-001], [PP-001], [MIL-010], [ADR-0006], [ADR-0010] | [MIL-012] | [RC-076], [RC-077], [RC-078], [RC-079] |
|
||||
| [MIL-012] | MIL | [BC-001], [PP-001], [MIL-004], [MIL-011], [ADR-0006] | - | [RC-080] |
|
||||
| [MIL-012] | MIL | [BC-001], [PP-001], [MIL-004], [MIL-011], [ADR-0006], [ADR-0011] | - | [RC-080], [RC-082] |
|
||||
| [RC-007] | RC | [MIL-004] | - | - |
|
||||
| [RC-008] | RC | [BC-001] | - | - |
|
||||
| [RC-009] | RC | [PP-001] | - | - |
|
||||
@@ -140,6 +141,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| [RC-079] | RC | [MIL-011] | - | - |
|
||||
| [RC-080] | RC | [MIL-012], [PP-001] | - | - |
|
||||
| [RC-081] | RC | [BC-001] | - | - |
|
||||
| [RC-082] | RC | [MIL-012] | - | - |
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
@@ -166,6 +168,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[MIL-012]: ../milestones/mil-012-vpn-actions-for-deployment.md
|
||||
[MIL-009]: ../milestones/mil-009-migrate-wiki-callers-and-remove-scoped-workflow.md
|
||||
[ADR-0010]: ../adr/adr-0010-code-metrics-badges-contract.md
|
||||
[ADR-0011]: ../adr/adr-0011-vpn-composite-actions.md
|
||||
[RC-001]: ./reviews/rc-001-business-case.md
|
||||
[RC-002]: ./reviews/rc-002-project-plan.md
|
||||
[RC-003]: ./reviews/rc-003-mil-001.md
|
||||
@@ -257,6 +260,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[RC-079]: ./reviews/rc-079-mil-011-outcome.md
|
||||
[RC-080]: ./reviews/rc-080-mil-012-plan.md
|
||||
[RC-081]: ./reviews/rc-081-bc-001-o6.md
|
||||
[RC-082]: ./reviews/rc-082-mil-012-results.md
|
||||
[ADR-0008]: ../adr/adr-0008-cpp-csharp-quality-check-contract.md
|
||||
[ADR-0009]: ../adr/adr-0009-wiki-publishing-reusable-workflow.md
|
||||
[ADR-0007]: ../adr/adr-0007-release-publish-destination.md
|
||||
@@ -267,5 +271,5 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[UC-005]: ../uc-005/uc.md
|
||||
[ADR-0005]: ../adr/adr-0005-quality-check-contract.md
|
||||
[3d7a9df]: https://git.tirsystem.com/TirSystem/github-action/commit/3d7a9df3a3a4712dcc5214a71b0569d90122bf4e
|
||||
[4aa3fd3]: https://git.tirsystem.com/TirSystem/github-action/commit/4aa3fd38f97c26a5d52abc566e958759583ccb85
|
||||
[c5d5941]: https://git.tirsystem.com/TirSystem/github-action/commit/c5d59418698316866807388bf61dcb6166d9fa8b
|
||||
[3325bea]: https://git.tirsystem.com/TirSystem/github-action/commit/3325bea772304b54e8dfffad592b4afadc3ec2b8
|
||||
|
||||
+27
-15
@@ -5,8 +5,8 @@ a Gitea Actions runner, so the next steps of the same job (a deployment over
|
||||
SSH, a migration) can reach hosts on the tailnet. It does what the GitHub
|
||||
marketplace action `tailscale/github-action` does, written for Gitea Actions:
|
||||
that action is built for GitHub and has not been verified on Gitea. It is
|
||||
**experimental**: it lives on the branch `wireguard-vpn-action` until a trial
|
||||
shows it is wanted. For a plain tunnel without a third-party service, see
|
||||
**experimental**: it is on `main` but not released, and it has not yet run on a
|
||||
runner with a real tailnet (MIL-012). For a plain tunnel without a third-party service, see
|
||||
[WireGuard](vpn-wireguard.md).
|
||||
|
||||
A node belongs to one job: every job that deploys joins on its own and logs out
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Connect Tailscale
|
||||
uses: TirSystem/github-action/actions/connect-tailscale@v2
|
||||
uses: TirSystem/github-action/actions/connect-tailscale@main
|
||||
with:
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:ci
|
||||
@@ -35,16 +35,17 @@ jobs:
|
||||
|
||||
- name: Disconnect Tailscale
|
||||
if: always()
|
||||
uses: TirSystem/github-action/actions/connect-tailscale@v2
|
||||
uses: TirSystem/github-action/actions/connect-tailscale@main
|
||||
with:
|
||||
action: down
|
||||
```
|
||||
|
||||
If the short form does not resolve on your instance (Gitea looks for the action
|
||||
on the host set in `DEFAULT_ACTIONS_URL`), use the full address:
|
||||
`https://git.tirsystem.com/TirSystem/github-action/actions/connect-tailscale@v2`.
|
||||
To try the branch before it is released, replace `@v2` with
|
||||
`@wireguard-vpn-action`.
|
||||
`https://git.tirsystem.com/TirSystem/github-action/actions/connect-tailscale@main`.
|
||||
|
||||
Until release 2.2.0 the actions exist only on `main`, so the examples call
|
||||
`@main`; `@v2` does not contain them yet. After the release, use `@v2`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -55,7 +56,8 @@ To try the branch before it is released, replace `@v2` with
|
||||
| `oauth-secret` | | The secret of an OAuth client with the scope `auth_keys`, as a secret. Needs `tags` |
|
||||
| `tags` | | ACL tags of the node, `tag:ci` or `tag:ci,tag:deploy`. Required with `oauth-secret` |
|
||||
| `hostname` | runner name | Name of the node in the tailnet |
|
||||
| `version` | `latest` | The version to install when the runner has no `tailscale`. Pin it |
|
||||
| `version` | | The exact version to install when the runner has no `tailscale`, for example `1.80.2`. Empty uses the installed one and fails when there is none |
|
||||
| `sha256` | | The SHA-256 of the downloaded archive `tailscale_<version>_<arch>.tgz`; the install fails when it differs. Recommended |
|
||||
| `userspace-networking` | `false` | `true` runs without a TUN device |
|
||||
| `args` | | Extra flags for `tailscale up`, for example `--accept-routes`. Never a secret |
|
||||
| `timeout` | `60` | Seconds to wait for the node |
|
||||
@@ -74,15 +76,23 @@ To try the branch before it is released, replace `@v2` with
|
||||
start with `GITEA_` or `GITHUB_`.
|
||||
4. Rotate the secret when a person with access leaves.
|
||||
|
||||
The node is ephemeral and its state is kept in memory, so it disappears from the
|
||||
tailnet when the job ends, even if the last step does not run.
|
||||
The state of the node is kept in memory. With an OAuth client the key is
|
||||
ephemeral, so the node disappears from the tailnet when the job ends, even if
|
||||
the last step does not run. With an auth key that is not ephemeral, a cancelled
|
||||
job can leave the node in the tailnet until `down` runs or it is removed in the
|
||||
admin console: create the key as ephemeral.
|
||||
|
||||
## The runner
|
||||
|
||||
- The job runs as root or can use `sudo`. The action downloads the Tailscale
|
||||
binaries from `pkgs.tailscale.com` when `tailscale` is not on the runner.
|
||||
Pin `version`; a runner image that already contains Tailscale skips the
|
||||
download.
|
||||
- The job runs as root, or `sudo` is available (the action uses `sudo` only
|
||||
when the job is not root). The action downloads the Tailscale binaries from
|
||||
`pkgs.tailscale.com` when `tailscale` is not on the runner; `version` is then
|
||||
required, and `sha256` makes the install check the archive. A runner image
|
||||
that already contains Tailscale skips the download.
|
||||
- The action starts its own `tailscaled`. It refuses to run when a `tailscaled`
|
||||
already answers on the runner, because that daemon belongs to the host and
|
||||
`down` would log it out: use the action on job containers, not on a runner
|
||||
that runs jobs directly on a host with its own Tailscale.
|
||||
- **TUN mode** (the default) needs the capability `NET_ADMIN` and the device
|
||||
`/dev/net/tun` in the runner container: in the `act_runner` configuration set
|
||||
under `container:` `options: "--cap-add=NET_ADMIN --device=/dev/net/tun"`.
|
||||
@@ -107,7 +117,9 @@ tailnet when the job ends, even if the last step does not run.
|
||||
never on a command line, and deletes the file at once;
|
||||
- prints errors as `::error::[category] message`, with the categories
|
||||
`configuration`, `environment` and `connection`;
|
||||
- on `down` logs the node out, stops `tailscaled` and deletes its work folder.
|
||||
- waits until `tailscaled` answers before `tailscale up` (up to 30 seconds);
|
||||
- on `down` logs the node out, stops `tailscaled` and deletes its work folder,
|
||||
and only when this action started that daemon.
|
||||
|
||||
The `file:` form of `--auth-key` needs a recent Tailscale; with an old pinned
|
||||
version `tailscale up` reports an invalid key.
|
||||
|
||||
+16
-10
@@ -4,7 +4,8 @@ The composite action `actions/connect-wireguard` brings a WireGuard tunnel up on
|
||||
a Gitea Actions runner, so the next steps of the same job (a deployment over
|
||||
SSH, a database migration) can reach hosts that are not on the internet. It is
|
||||
the WireGuard counterpart of the Tailscale action. It is **experimental**: it
|
||||
lives on a branch until a trial shows it is wanted.
|
||||
is on `main` but not released, and it has not yet run on a runner with a real
|
||||
tunnel (MIL-012).
|
||||
|
||||
A tunnel belongs to one job: every job that deploys brings its own tunnel up
|
||||
and takes it down. It cannot be a reusable workflow, because a called workflow
|
||||
@@ -20,7 +21,7 @@ jobs:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Connect the VPN
|
||||
uses: TirSystem/github-action/actions/connect-wireguard@v2
|
||||
uses: TirSystem/github-action/actions/connect-wireguard@main
|
||||
with:
|
||||
config: ${{ secrets.WG_CONFIG }}
|
||||
health-check-host: 10.8.0.1
|
||||
@@ -30,17 +31,17 @@ jobs:
|
||||
|
||||
- name: Disconnect the VPN
|
||||
if: always()
|
||||
uses: TirSystem/github-action/actions/connect-wireguard@v2
|
||||
uses: TirSystem/github-action/actions/connect-wireguard@main
|
||||
with:
|
||||
action: down
|
||||
```
|
||||
|
||||
If the short form does not resolve on your instance (Gitea looks for the action
|
||||
on the host set in `DEFAULT_ACTIONS_URL`), use the full address:
|
||||
`https://git.tirsystem.com/TirSystem/github-action/actions/connect-wireguard@v2`.
|
||||
`https://git.tirsystem.com/TirSystem/github-action/actions/connect-wireguard@main`.
|
||||
|
||||
To try the branch before it is released, replace `@v2` with
|
||||
`@wireguard-vpn-action`.
|
||||
Until release 2.2.0 the actions exist only on `main`, so the examples call
|
||||
`@main`; `@v2` does not contain them yet. After the release, use `@v2`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -71,8 +72,9 @@ To try the branch before it is released, replace `@v2` with
|
||||
A tunnel needs a network interface, so the runner needs more than a plain job
|
||||
container:
|
||||
|
||||
- the job runs as root or can use `sudo` (the action installs
|
||||
`wireguard-tools` with `apt` when it is missing, so a Debian or Ubuntu image);
|
||||
- the job runs as root, or `sudo` is available (the action uses `sudo` only
|
||||
when the job is not root, and installs `wireguard-tools` with `apt` when it is
|
||||
missing, so a Debian or Ubuntu image);
|
||||
- the container has the capability `NET_ADMIN` and the device `/dev/net/tun`.
|
||||
In the `act_runner` configuration set under `container:`
|
||||
`options: "--cap-add=NET_ADMIN --device=/dev/net/tun"`;
|
||||
@@ -85,9 +87,13 @@ privilege.
|
||||
|
||||
## What the action does
|
||||
|
||||
- masks the keys in the log before anything else;
|
||||
- masks the keys in the log before anything else (the values of `PrivateKey`
|
||||
and `PresharedKey` of a full configuration, and the key inputs);
|
||||
- refuses an input that holds a line break, because an extra line in the file
|
||||
could add a `PostUp` command that runs as root;
|
||||
- writes `/etc/wireguard/<interface>.conf` with mode 600 and brings it up with
|
||||
`wg-quick`;
|
||||
`wg-quick` (through `env`, so the `wireguard-go` choice survives `sudo`); the
|
||||
file is removed again when the interface does not come up;
|
||||
- prints errors as `::error::[category] message`, with the categories
|
||||
`configuration`, `environment` and `connection`;
|
||||
- on `down` takes the interface down and deletes the file.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pytest>=8,<10
|
||||
PyYAML>=6,<7
|
||||
coverage>=7,<8
|
||||
shellcheck-py==0.10.0.1
|
||||
shfmt-py==3.11.0.2
|
||||
|
||||
@@ -36,6 +36,11 @@ case "$1" in
|
||||
done
|
||||
exit "${UP_EXIT:-0}" ;;
|
||||
ping) exit "${PING_EXIT:-0}" ;;
|
||||
version)
|
||||
# the daemon answers only after tailscaled was started, and after DAEMON_DELAY calls
|
||||
[ -f "$STUB_DAEMON" ] || exit 1
|
||||
n=$(( $(cat "$STUB_DAEMON") + 1 )); echo "$n" > "$STUB_DAEMON"
|
||||
[ "$n" -gt "${DAEMON_DELAY:-0}" ] ;;
|
||||
esac
|
||||
"""
|
||||
|
||||
@@ -52,13 +57,19 @@ def make_stub(path: Path, body: str) -> None:
|
||||
|
||||
def run(tmp_path: Path, command: str, **env: str) -> subprocess.CompletedProcess:
|
||||
make_stub(tmp_path / "tailscale", TAILSCALE_STUB)
|
||||
make_stub(tmp_path / "tailscaled", "#!/usr/bin/env bash\necho \"$*\" >> \"$STUB_LOG\"\n")
|
||||
make_stub(
|
||||
tmp_path / "tailscaled",
|
||||
'#!/usr/bin/env bash\necho "$*" >> "$STUB_LOG"\necho 0 > "$STUB_DAEMON"\n',
|
||||
)
|
||||
full = {
|
||||
"PATH": f"{tmp_path}:{os.environ['PATH']}",
|
||||
"TS_SUDO": "",
|
||||
"TS_STATE_DIR": str(tmp_path / "state"),
|
||||
"STUB_LOG": str(tmp_path / "log"),
|
||||
"STUB_KEY": str(tmp_path / "key"),
|
||||
"STUB_DAEMON": str(tmp_path / "daemon"),
|
||||
"TS_WAIT_INTERVAL": "0.05",
|
||||
"TS_START_WAIT": "100",
|
||||
**env,
|
||||
}
|
||||
return subprocess.run(
|
||||
@@ -77,7 +88,7 @@ class TestDefinition:
|
||||
def test_inputs_are_the_contract(self, document):
|
||||
assert set(document["inputs"]) == {
|
||||
"action", "authkey", "oauth-secret", "tags", "hostname", "version",
|
||||
"userspace-networking", "args", "timeout", "health-check-host",
|
||||
"sha256", "userspace-networking", "args", "timeout", "health-check-host",
|
||||
}
|
||||
|
||||
def test_uses_gitea_contexts_only(self):
|
||||
@@ -124,6 +135,8 @@ class TestUp:
|
||||
({"TS_AUTHKEY": AUTHKEY, "TS_TAGS": "ci"}, "tags must look like"),
|
||||
({"TS_AUTHKEY": AUTHKEY, "TS_TIMEOUT": "soon"}, "timeout"),
|
||||
({"TS_AUTHKEY": AUTHKEY, "TS_VERSION": "1.x"}, "version"),
|
||||
({"TS_AUTHKEY": AUTHKEY, "TS_VERSION": "latest"}, "version"),
|
||||
({"TS_AUTHKEY": AUTHKEY, "TS_SHA256": "abc"}, "sha256"),
|
||||
({"TS_AUTHKEY": AUTHKEY, "TS_USERSPACE": "yes"}, "userspace-networking"),
|
||||
({"TS_AUTHKEY": AUTHKEY, "TS_HOSTNAME": "bad name"}, "hostname"),
|
||||
],
|
||||
@@ -148,6 +161,40 @@ class TestUp:
|
||||
assert "health-check-host" in result.stdout
|
||||
|
||||
|
||||
class TestDaemon:
|
||||
def test_waits_until_the_daemon_answers(self, tmp_path):
|
||||
result = run(tmp_path, "up", TS_AUTHKEY=AUTHKEY, DAEMON_DELAY="5")
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert "up --auth-key" in log(tmp_path)
|
||||
|
||||
def test_a_daemon_that_never_answers_fails_before_up(self, tmp_path):
|
||||
result = run(tmp_path, "up", TS_AUTHKEY=AUTHKEY, DAEMON_DELAY="1000", TS_START_WAIT="3")
|
||||
assert result.returncode == 1
|
||||
assert "::error::[connection] tailscaled did not answer" in result.stdout
|
||||
assert "up --auth-key" not in log(tmp_path)
|
||||
|
||||
def test_a_daemon_of_the_host_is_not_taken_over(self, tmp_path):
|
||||
(tmp_path / "daemon").write_text("0", encoding="utf-8")
|
||||
result = run(tmp_path, "up", TS_AUTHKEY=AUTHKEY)
|
||||
assert result.returncode == 1
|
||||
assert "::error::[environment] a tailscaled already runs" in result.stdout
|
||||
calls = log(tmp_path) # only the probe ran: no second daemon, no up
|
||||
assert "--state=mem:" not in calls
|
||||
assert "up --auth-key" not in calls
|
||||
|
||||
def test_down_leaves_a_daemon_it_did_not_start(self, tmp_path):
|
||||
(tmp_path / "daemon").write_text("0", encoding="utf-8")
|
||||
result = run(tmp_path, "down")
|
||||
assert result.returncode == 0
|
||||
assert "nothing to do" in result.stdout
|
||||
assert not (tmp_path / "log").exists()
|
||||
|
||||
def test_without_tailscale_a_version_is_required(self, tmp_path):
|
||||
result = run(tmp_path, "up", TS_AUTHKEY=AUTHKEY, TS_TAILSCALE="no-such-tailscale")
|
||||
assert result.returncode == 1
|
||||
assert "::error::[configuration] tailscale is not on the runner; set version" in result.stdout
|
||||
|
||||
|
||||
class TestDownAndMask:
|
||||
def test_down_logs_out_and_removes_the_state(self, tmp_path):
|
||||
run(tmp_path, "up", TS_AUTHKEY=AUTHKEY)
|
||||
|
||||
@@ -131,6 +131,40 @@ class TestUp:
|
||||
assert "::error::[connection]" in result.stdout
|
||||
|
||||
|
||||
def make_exec(path: Path, body: str) -> Path:
|
||||
path.write_text(body, encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR)
|
||||
return path
|
||||
|
||||
|
||||
class TestEnvironment:
|
||||
def test_the_userspace_variable_reaches_wg_quick_through_sudo(self, tmp_path):
|
||||
# a sudo that resets the environment, as the real one does
|
||||
sudo = make_exec(
|
||||
tmp_path / "sudo-reset",
|
||||
'#!/usr/bin/env bash\nexec env -i "PATH=$PATH" "STUB_LOG=$STUB_LOG" "$@"\n',
|
||||
)
|
||||
quick = make_exec(
|
||||
tmp_path / "quick-env",
|
||||
'#!/usr/bin/env bash\necho "$WG_QUICK_USERSPACE_IMPLEMENTATION $*" >> "$STUB_LOG"\n',
|
||||
)
|
||||
result = run(tmp_path, "up", **FIELDS, WG_SUDO=str(sudo), WG_QUICK=str(quick))
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "wireguard-go up wg0" in (tmp_path / "log").read_text(encoding="utf-8")
|
||||
|
||||
def test_a_field_with_a_line_break_is_refused(self, tmp_path):
|
||||
result = run(tmp_path, "up", **{**FIELDS, "WG_ALLOWED_IPS": "10.8.0.0/24\nPostUp = id"})
|
||||
assert result.returncode == 1
|
||||
assert "WG_ALLOWED_IPS must be one line" in result.stdout
|
||||
assert not (tmp_path / "etc" / "wg0.conf").exists()
|
||||
|
||||
def test_the_key_file_is_removed_when_wg_quick_fails(self, tmp_path):
|
||||
failing = make_exec(tmp_path / "quick-fail", "#!/usr/bin/env bash\nexit 1\n")
|
||||
result = run(tmp_path, "up", **FIELDS, WG_QUICK=str(failing))
|
||||
assert result.returncode == 1
|
||||
assert not (tmp_path / "etc" / "wg0.conf").exists()
|
||||
|
||||
|
||||
class TestDownAndMask:
|
||||
def test_down_removes_the_config(self, tmp_path):
|
||||
run(tmp_path, "up", **FIELDS)
|
||||
@@ -141,7 +175,8 @@ class TestDownAndMask:
|
||||
def test_mask_prints_every_secret(self, tmp_path):
|
||||
result = run(tmp_path, "mask", WG_PRIVATE_KEY=KEY, WG_CONFIG="[Interface]\nPrivateKey = abc")
|
||||
assert f"::add-mask::{KEY}" in result.stdout
|
||||
assert "::add-mask::PrivateKey = abc" in result.stdout
|
||||
assert "::add-mask::abc" in result.stdout
|
||||
assert "[Interface]" not in result.stdout # only the key values are masked
|
||||
|
||||
def test_unknown_command_fails(self, tmp_path):
|
||||
assert run(tmp_path, "sideways").returncode == 1
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Offline tests for tools/lint-action-scripts.sh (MIL-012 task 6).
|
||||
|
||||
The script runs shellcheck and shfmt on a folder; the tests give it a clean
|
||||
script, a script with a shellcheck problem and a badly formatted one, so a
|
||||
seeded error is shown to fail the run.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from workflow_source import REPO_ROOT
|
||||
|
||||
SCRIPT = REPO_ROOT / "tools" / "lint-action-scripts.sh"
|
||||
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "validate-workflows.yml"
|
||||
BASH = shutil.which("bash")
|
||||
# the tools are in the virtual environment of the interpreter that runs the tests
|
||||
SEARCH_PATH = os.pathsep.join([str(Path(sys.executable).parent), os.environ.get("PATH", "")])
|
||||
SHELLCHECK = shutil.which("shellcheck", path=SEARCH_PATH)
|
||||
SHFMT = shutil.which("shfmt", path=SEARCH_PATH)
|
||||
HAVE_TOOLS = BASH and SHELLCHECK and SHFMT
|
||||
|
||||
CLEAN = '#!/usr/bin/env bash\nmain() {\n echo "$1"\n}\n\nmain "$@"\n'
|
||||
SHELLCHECK_PROBLEM = '#!/usr/bin/env bash\nmain() {\n echo $1\n}\n\nmain "$@"\n'
|
||||
BADLY_FORMATTED = '#!/usr/bin/env bash\nmain() {\n echo "$1"\n}\n\nmain "$@"\n'
|
||||
|
||||
|
||||
def lint(folder: Path) -> subprocess.CompletedProcess:
|
||||
# shfmt reads .editorconfig from the folder upwards; a temporary folder is
|
||||
# outside this repository, so it gets a copy of the repository's file
|
||||
if folder != REPO_ROOT / "actions":
|
||||
shutil.copy(REPO_ROOT / ".editorconfig", folder / ".editorconfig")
|
||||
env = {**os.environ, "SHELLCHECK": str(SHELLCHECK), "SHFMT": str(SHFMT)}
|
||||
return subprocess.run(
|
||||
[BASH, str(SCRIPT), str(folder)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_TOOLS, reason="bash, shellcheck and shfmt are needed")
|
||||
class TestLint:
|
||||
def test_a_clean_script_passes(self, tmp_path):
|
||||
(tmp_path / "ok.sh").write_bytes(CLEAN.encode()) # LF on every platform
|
||||
result = lint(tmp_path)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "clean" in result.stdout
|
||||
|
||||
def test_a_shellcheck_problem_fails(self, tmp_path):
|
||||
(tmp_path / "bad.sh").write_bytes(SHELLCHECK_PROBLEM.encode()) # LF on every platform
|
||||
result = lint(tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "::error::[lint] shellcheck" in result.stdout
|
||||
|
||||
def test_a_badly_formatted_script_fails(self, tmp_path):
|
||||
(tmp_path / "ugly.sh").write_bytes(BADLY_FORMATTED.encode()) # LF on every platform
|
||||
result = lint(tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "::error::[lint] shfmt" in result.stdout
|
||||
|
||||
def test_no_scripts_is_not_a_failure(self, tmp_path):
|
||||
result = lint(tmp_path)
|
||||
assert result.returncode == 0
|
||||
assert "nothing was checked" in result.stdout
|
||||
|
||||
def test_the_scripts_of_the_actions_are_clean(self):
|
||||
result = lint(REPO_ROOT / "actions")
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
class TestWorkflow:
|
||||
def test_validate_workflows_runs_the_lint_with_pinned_tools(self):
|
||||
document = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
commands = [step.get("run", "") for step in document["jobs"]["validate"]["steps"]]
|
||||
assert any("tools/lint-action-scripts.sh" in command for command in commands)
|
||||
requirements = (REPO_ROOT / "tests" / "requirements.txt").read_text(encoding="utf-8")
|
||||
assert "shellcheck-py==" in requirements
|
||||
assert "shfmt-py==" in requirements
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Lint the shell scripts of the composite actions (MIL-012 task 6): shellcheck,
|
||||
# and shfmt in the format .editorconfig sets (two spaces, indented case labels).
|
||||
# validate-workflows.yml runs it on every pull request and push to main.
|
||||
#
|
||||
# Usage: lint-action-scripts.sh [<directory>] (default: actions)
|
||||
# Env: SHELLCHECK, SHFMT the commands to run (default: from the PATH)
|
||||
# Exit: 0 every script is clean (or there is none); 1 at least one finding or
|
||||
# a missing tool
|
||||
# Needs: bash, find, shellcheck, shfmt
|
||||
|
||||
SHELLCHECK="${SHELLCHECK:-shellcheck}"
|
||||
SHFMT="${SHFMT:-shfmt}"
|
||||
|
||||
main() {
|
||||
set -u
|
||||
local dir="${1:-actions}" failed=0 tool
|
||||
for tool in "$SHELLCHECK" "$SHFMT"; do
|
||||
command -v "$tool" >/dev/null 2>&1 || {
|
||||
echo "::error::[environment] $tool is not installed."
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
local files=()
|
||||
while IFS= read -r file; do
|
||||
files+=("$file")
|
||||
done < <(find "$dir" -name '*.sh' -type f | sort)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "::notice::No shell scripts under '$dir'; nothing was checked."
|
||||
exit 0
|
||||
fi
|
||||
"$SHELLCHECK" "${files[@]}" || {
|
||||
echo "::error::[lint] shellcheck found problems."
|
||||
failed=1
|
||||
}
|
||||
"$SHFMT" -d "${files[@]}" || {
|
||||
echo "::error::[lint] shfmt would change a script; run shfmt -w."
|
||||
failed=1
|
||||
}
|
||||
[ "$failed" -eq 0 ] && echo "Checked ${#files[@]} script(s): clean."
|
||||
exit "$failed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user