Compare commits
No commits in common. "main" and "0.3.1" have entirely different histories.
47 changed files with 127 additions and 4292 deletions
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
|
|
@ -11,9 +11,7 @@
|
||||||
"scope:runner":
|
"scope:runner":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
["commands/runner-*.sh", "commands/lib/runner-config.sh",
|
["commands/runner-*.sh", "commands/lib/runner-config.sh"]
|
||||||
"commands/forgejo-runner-*.sh", "commands/lib/forgejo-runner-config.sh",
|
|
||||||
"docs/templates/ci-box/**"]
|
|
||||||
"scope:coolify":
|
"scope:coolify":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["commands/coolify-*.sh"]
|
- any-glob-to-any-file: ["commands/coolify-*.sh"]
|
||||||
|
|
|
||||||
6
.github/labels.conf
vendored
6
.github/labels.conf
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
panel=cluade-reviewer-andresmgsl codex-reviewer-andresmgsl grok-reviewer-andresmgsl kimi-reviewer-andresmgsl
|
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
|
||||||
triage-actors=dan-claude-bot cluade-reviewer-andresmgsl
|
triage-actors=dan-claude-bot
|
||||||
scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
|
scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
|
||||||
scope:users|C5DEF5|users-* — class model, apply/status, close-root
|
scope:users|C5DEF5|users-* — class model, apply/status, close-root
|
||||||
scope:runner|C5DEF5|runner-* / forgejo-runner-* — CI runner lifecycle, either forge
|
scope:runner|C5DEF5|runner-* — GitHub runner lifecycle
|
||||||
scope:coolify|C5DEF5|coolify-* — Coolify and backup install
|
scope:coolify|C5DEF5|coolify-* — Coolify and backup install
|
||||||
scope:db|C5DEF5|db.sh — dump/restore
|
scope:db|C5DEF5|db.sh — dump/restore
|
||||||
scope:installer|C5DEF5|install.sh — how rig lands on a machine
|
scope:installer|C5DEF5|install.sh — how rig lands on a machine
|
||||||
|
|
|
||||||
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
|
|
@ -26,21 +26,7 @@ jobs:
|
||||||
# The file list is printed so under-coverage shows up in the log, and
|
# The file list is printed so under-coverage shows up in the log, and
|
||||||
# the comm below turns under-coverage into a failure rather than a
|
# the comm below turns under-coverage into a failure rather than a
|
||||||
# thing someone has to notice: every tracked `.sh` must be in the set.
|
# thing someone has to notice: every tracked `.sh` must be in the set.
|
||||||
#
|
|
||||||
# Install shellcheck when missing (#144). rig's default Forgejo label
|
|
||||||
# maps ubuntu-latest to catthehacker's act-22.04 (slim), which does not
|
|
||||||
# ship shellcheck; GitHub-hosted ubuntu-latest does. The conditional
|
|
||||||
# keeps each forge from paying for the other.
|
|
||||||
#
|
|
||||||
# sudo: load-bearing on GitHub (job runs as `runner` with passwordless
|
|
||||||
# sudo) and a no-op on act-22.04 (jobs run as uid 0; the image has no
|
|
||||||
# `runner` account). Do not delete it as "dead weight" — that breaks
|
|
||||||
# the GitHub half the day that image stops preinstalling shellcheck.
|
|
||||||
run: |
|
run: |
|
||||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y shellcheck
|
|
||||||
fi
|
|
||||||
shopt -s globstar dotglob
|
shopt -s globstar dotglob
|
||||||
files=(bin/* **/*.sh)
|
files=(bin/* **/*.sh)
|
||||||
printf 'shellcheck: %s\n' "${files[@]}"
|
printf 'shellcheck: %s\n' "${files[@]}"
|
||||||
|
|
@ -64,38 +50,26 @@ jobs:
|
||||||
# lives with its implementation upstream; the six pins below and the
|
# lives with its implementation upstream; the six pins below and the
|
||||||
# two workflow callers must always name the same ceremony tag.
|
# two workflow callers must always name the same ceremony tag.
|
||||||
#
|
#
|
||||||
# These six are ABSOLUTE and the two callers in release.yml/labels.yml
|
|
||||||
# are BARE. That asymmetry is measured, not stylistic (#112) — do not
|
|
||||||
# "tidy" either half into the other:
|
|
||||||
# a step `uses:` resolves a bare name through DEFAULT_ACTIONS_URL,
|
|
||||||
# which this forge points at code.forgejo.org, where ceremony does not
|
|
||||||
# exist — bare 404s and every guard below fails;
|
|
||||||
# a reusable-workflow `uses:` never consults DEFAULT_ACTIONS_URL at
|
|
||||||
# all — it resolves against the runner's own instance, so bare already
|
|
||||||
# works there. And release.yml's caller is the line ceremony's own
|
|
||||||
# docs-sync greps for its pin, anchored to the bare spelling: make it
|
|
||||||
# absolute and the pin goes invisible and docs-sync exits 1.
|
|
||||||
#
|
|
||||||
# changelog-armed: the version-keyed arming rule (rig#66; the
|
# changelog-armed: the version-keyed arming rule (rig#66; the
|
||||||
# unconditional form rig#44 reverted — this is its correct return).
|
# unconditional form rig#44 reverted — this is its correct return).
|
||||||
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-armed@0.3.0
|
- uses: heavy-duty/ceremony/actions/changelog-armed@0.3.0
|
||||||
# changelog-monotonic: no shipped heading deleted or duplicated
|
# changelog-monotonic: no shipped heading deleted or duplicated
|
||||||
# (#98, box#122). Strict by default: an unresolvable base ref is red,
|
# (#98, box#122). Strict by default: an unresolvable base ref is red,
|
||||||
# never a quiet skip — hence the fetch-depth: 0 above.
|
# never a quiet skip — hence the fetch-depth: 0 above.
|
||||||
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-monotonic@0.3.0
|
- uses: heavy-duty/ceremony/actions/changelog-monotonic@0.3.0
|
||||||
# changelog-assembled: a release's stamped section must exactly match
|
# changelog-assembled: a release's stamped section must exactly match
|
||||||
# the fragments it consumed. Vacuous on non-release PRs.
|
# the fragments it consumed. Vacuous on non-release PRs.
|
||||||
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/changelog-assembled@0.3.0
|
- uses: heavy-duty/ceremony/actions/changelog-assembled@0.3.0
|
||||||
# drill-recorded: a release version carries drills/<version>.md
|
# drill-recorded: a release version carries drills/<version>.md
|
||||||
# (rig's drill meaning: drills/README.md). Vacuous on -dev trees.
|
# (rig's drill meaning: drills/README.md). Vacuous on -dev trees.
|
||||||
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/drill-recorded@0.3.0
|
- uses: heavy-duty/ceremony/actions/drill-recorded@0.3.0
|
||||||
# runner-isolated: PR-triggered workflows never execute unreviewed
|
# runner-isolated: PR-triggered workflows never execute unreviewed
|
||||||
# branch code on a self-hosted runner.
|
# branch code on a self-hosted runner.
|
||||||
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/runner-isolated@0.3.0
|
- uses: heavy-duty/ceremony/actions/runner-isolated@0.3.0
|
||||||
# docs-sync: the .ceremony/ doctrine mirror is byte-identical to the
|
# docs-sync: the .ceremony/ doctrine mirror is byte-identical to the
|
||||||
# pin read from release.yml (ceremony#19) — a hand edit or a
|
# pin read from release.yml (ceremony#19) — a hand edit or a
|
||||||
# half-done pin bump goes red here.
|
# half-done pin bump goes red here.
|
||||||
- uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/docs-sync@0.3.0
|
- uses: heavy-duty/ceremony/actions/docs-sync@0.3.0
|
||||||
|
|
||||||
# The install LIFECYCLE against a tree install.sh itself produced — the four
|
# The install LIFECYCLE against a tree install.sh itself produced — the four
|
||||||
# beats box and cast already run in CI (#106): install from this checkout,
|
# beats box and cast already run in CI (#106): install from this checkout,
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ genuinely rig's.
|
||||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
||||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
||||||
2. **The review panel** (`.github/labels.conf`'s `panel=` line):
|
2. **The review panel** (`.github/labels.conf`'s `panel=` line):
|
||||||
`cluade-reviewer-andresmgsl`, `codex-reviewer-andresmgsl`,
|
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`,
|
||||||
`grok-reviewer-andresmgsl`, `kimi-reviewer-andresmgsl` —
|
`kimi-bot-andresmgsl` —
|
||||||
the required verdicts for a PR are the panel minus its author. The
|
the required verdicts for a PR are the panel minus its author. The
|
||||||
maintainer (`danmt`) takes the last word and merges.
|
maintainer (`danmt`) takes the last word and merges.
|
||||||
3. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
3. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
||||||
|
|
|
||||||
221
README.md
221
README.md
|
|
@ -19,11 +19,6 @@ takes arguments, does its work, and stores no credential, ever.
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | RIG_REF=main bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | RIG_REF=main bash
|
||||||
# the latest release:
|
# the latest release:
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
||||||
# the same two channels from the Forgejo mirror (RIG_HOST picks the forge; #111):
|
|
||||||
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/rig/raw/branch/main/install.sh \
|
|
||||||
| RIG_HOST=https://forgejo.heavyduty.builders RIG_REF=main bash
|
|
||||||
curl -fsSL https://forgejo.heavyduty.builders/heavy-duty/rig/raw/branch/main/install.sh \
|
|
||||||
| RIG_HOST=https://forgejo.heavyduty.builders bash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This README tracks `main`, so the quick start installs that same development
|
This README tracks `main`, so the quick start installs that same development
|
||||||
|
|
@ -42,11 +37,6 @@ curl -fsSL .../install.sh | RIG_REF=main bash # the development tree
|
||||||
A tag outranks a branch of the same name (the pin must win); anything that
|
A tag outranks a branch of the same name (the pin must win); anything that
|
||||||
is not a tag falls back to `refs/heads/<ref>`.
|
is not a tag falls back to `refs/heads/<ref>`.
|
||||||
|
|
||||||
`RIG_HOST` picks the forge the channel reads — `https://github.com` by
|
|
||||||
default (#111) — and it is chosen independently of the URL the script itself
|
|
||||||
came from, so installing *from* the Forgejo mirror means naming the mirror
|
|
||||||
twice, as the quick start's third line does.
|
|
||||||
|
|
||||||
The layout, under the install root (`~/.local/share/rig`):
|
The layout, under the install root (`~/.local/share/rig`):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -411,34 +401,6 @@ unattended VM-host appliance) — and `workstation` is the machine at the keyboa
|
||||||
end of all the SSH connections: `root-door=closed`, `join=login`, entering the
|
end of all the SSH connections: `root-door=closed`, `join=login`, entering the
|
||||||
tailnet as *your* device rather than the fleet's.
|
tailnet as *your* device rather than the fleet's.
|
||||||
|
|
||||||
### Machine-role templates
|
|
||||||
|
|
||||||
Machine presets can also live in the
|
|
||||||
[heavy-duty/rig-templates](https://github.com/heavy-duty/rig-templates)
|
|
||||||
registry. A `*-server` directory is a fleet-machine definition; the exact
|
|
||||||
name `workstation` is the deliberate suffix-less exception. Its
|
|
||||||
`template.env` contains exactly the three traits bootstrap's built-in table
|
|
||||||
uses:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
ROOT_DOOR="open" # open|closed
|
|
||||||
HOST="no" # yes|no
|
|
||||||
JOIN="authkey" # authkey|login
|
|
||||||
```
|
|
||||||
|
|
||||||
An `install.sh` is optional. When present, bootstrap runs it as root,
|
|
||||||
non-interactively, from the definition directory with `RIG_ROLE` set, after
|
|
||||||
the tailnet join, host setup, role marker prerequisites, and operator
|
|
||||||
convergence. A nonzero exit fails bootstrap and names the role and registry
|
|
||||||
source. The definition owns idempotence, just as bootstrap does.
|
|
||||||
|
|
||||||
Built-in roles and `custom` take precedence over registry names. Any other
|
|
||||||
non-tenant role is looked up in the resolved registry; the same three source
|
|
||||||
knobs below apply, including `RIG_TEMPLATES_DIR` for an offline local
|
|
||||||
definition. Pin reviewed registry content into rig's tree before using it on
|
|
||||||
fleet machines: an optional machine `install.sh` executes as root on metal,
|
|
||||||
and an override is the operator explicitly choosing a different trust root.
|
|
||||||
|
|
||||||
### `rig bootstrap <role>-box` — the box tenants
|
### `rig bootstrap <role>-box` — the box tenants
|
||||||
|
|
||||||
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
|
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
|
||||||
|
|
@ -474,35 +436,13 @@ actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacen
|
||||||
(sshd hardening through the shared `lib/sshd.sh`, docker, no agent), user
|
(sshd hardening through the shared `lib/sshd.sh`, docker, no agent), user
|
||||||
`ops`, box#69's server posture with `root-door=open` acceptance.
|
`ops`, box#69's server posture with `root-door=open` acceptance.
|
||||||
|
|
||||||
**Where the registry comes from — precedence high to low:**
|
**Where the registry comes from — three knobs, precedence high to low:**
|
||||||
|
|
||||||
| knob | meaning |
|
| knob | meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" |
|
| `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" |
|
||||||
| `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time |
|
| `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time |
|
||||||
| `RIG_TEMPLATES_HOST` | which **forge** that repo lives on (default `https://github.com`) — not a mirror knob but a URL-grammar one: GitHub serves three candidate archive paths (`refs/tags`, `refs/heads`, bare) and Forgejo serves exactly one (`/archive/<ref>.tar.gz`), so the host decides what is even worth requesting. `install.sh` reads the same variable for its snapshot, so the two cannot disagree about where the registry is |
|
| *(neither set)* | **the in-tree pin** — `RIG_TEMPLATES_PIN` in `commands/lib/templates.sh`, the `BOX_RELEASE` discipline: bumped by ordinary reviewed rig PR, so a rig release freezes the mechanism+registry pair, and a newer rig matches newer templates by default (the #110 ruling) |
|
||||||
| *(neither set; matching snapshot installed)* | **the installed pin snapshot** — `install.sh` best-effort fetches `RIG_TEMPLATES_PIN` once into `templates@<pin-sha>/` inside the versioned rig tree; default converges read it with zero registry network I/O |
|
|
||||||
| *(snapshot absent, empty, or stale)* | **live fetch of the in-tree pin** — the pre-snapshot fallback: `RIG_TEMPLATES_PIN` in `commands/lib/templates.sh` is fetched at converge time. A failed snapshot download only warns during install, so rig remains usable and retries here |
|
|
||||||
|
|
||||||
The pin remains the only source of truth. An older `templates@<sha>/`
|
|
||||||
directory cannot answer after a pin bump, and an explicit
|
|
||||||
`RIG_TEMPLATES_REF` always fetches that ref rather than consulting the
|
|
||||||
snapshot. Logs mark the installed path as `(snapshot)` so drill evidence
|
|
||||||
records which source actually served the converge.
|
|
||||||
|
|
||||||
> **A self-hosted forge must serve public repos anonymously.** The fetch is
|
|
||||||
> unauthenticated *by contract* — box auto-runs a tenant bootstrap at mint,
|
|
||||||
> holding nothing — so `RIG_TEMPLATES_HOST` can only point somewhere a
|
|
||||||
> credential-less `curl` succeeds. Forgejo with `REQUIRE_SIGNIN_VIEW=true`
|
|
||||||
> answers **404 for a repo it reports as public**, which is exactly what a
|
|
||||||
> wrong ref looks like; the refusal names this case rather than leaving you
|
|
||||||
> hunting for a typo. `FORGEJO__service__REQUIRE_SIGNIN_VIEW=false` is what
|
|
||||||
> makes an instance usable as a registry host.
|
|
||||||
>
|
|
||||||
> Verified on `forgejo.heavyduty.builders` (2026-07-27): a credential-less
|
|
||||||
> `templates_resolve` against `RIG_TEMPLATES_HOST=https://forgejo.heavyduty.builders`
|
|
||||||
> fetches and extracts a real repository archive. The mint-time path works
|
|
||||||
> there today.
|
|
||||||
|
|
||||||
**The security trade — in bold, not a footnote.** **A main-tracked
|
**The security trade — in bold, not a footnote.** **A main-tracked
|
||||||
rig-templates repo means every merged PR there executes as root inside every
|
rig-templates repo means every merged PR there executes as root inside every
|
||||||
|
|
@ -1079,163 +1019,6 @@ prints the exact `runner install` line that finishes the job.
|
||||||
Convergent — repointing to the repo it is already on changes nothing, exits 0,
|
Convergent — repointing to the repo it is already on changes nothing, exits 0,
|
||||||
and never asks for a token.
|
and never asks for a token.
|
||||||
|
|
||||||
### `rig forgejo-runner install --instance <url>`
|
|
||||||
|
|
||||||
The other forge's runner, and a different shape of box. Where `rig runner`
|
|
||||||
converges a fleet **machine** (`runner-server`), this converges a **ci-box
|
|
||||||
tenant** — a box guest whose whole job is running CI:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
box mint ci-box # box auto-runs: rig bootstrap ci-box
|
|
||||||
box shell ci-box
|
|
||||||
sudo rig forgejo-runner install --instance https://forgejo.heavyduty.builders
|
|
||||||
```
|
|
||||||
|
|
||||||
Installs `forgejo-runner` as a systemd service under an unprivileged user
|
|
||||||
(default: the tenant user `ci`). Like its GitHub sibling the runner is an
|
|
||||||
agent, not a server — it long-polls the instance outbound and needs **zero
|
|
||||||
inbound ports**.
|
|
||||||
|
|
||||||
**Jobs run in containers, and there is no docker-in-docker.**
|
|
||||||
`rig bootstrap ci-box` already installed Docker and put the tenant user in the
|
|
||||||
`docker` group, so the runner drives that daemon directly. The usual dind
|
|
||||||
sidecar — privileged, with a plaintext `tcp://…:2375` socket — exists to
|
|
||||||
isolate jobs from a *shared* CI server; inside a box that boundary is already
|
|
||||||
paid for. The box is network-isolated, has no inbound path, and is thrown away.
|
|
||||||
|
|
||||||
That is also why this command allows what `rig runner install` refuses. Docker
|
|
||||||
group membership is root-equivalent, and on a fleet machine the blast radius is
|
|
||||||
the machine. Here it is a disposable guest. Same trade, different box, opposite
|
|
||||||
answer — which is why these are two commands and not one with a `--forge` flag.
|
|
||||||
|
|
||||||
- `--version <pin>` — `forgejo-runner` release (default: latest at install
|
|
||||||
time). **Convergent, and it replaces an existing binary** — including
|
|
||||||
downward, because a pin is an instruction rather than a floor. Without a
|
|
||||||
pin, a binary already on the box is left alone: chasing "latest" on every
|
|
||||||
converge would make a plain re-run an unrequested upgrade.
|
|
||||||
The published `.sha256` is **verified before the binary is installed**, and
|
|
||||||
a missing or unreadable checksum **refuses** just as a mismatch does — this
|
|
||||||
binary runs as root under a systemd unit, so the gate does not fail open.
|
|
||||||
|
|
||||||
> Unlike `rig runner install`, presence alone is not enough to skip the
|
|
||||||
> download here. That command can skip because `actions/runner`
|
|
||||||
> **self-updates**; `forgejo-runner` does not, so nothing else would ever
|
|
||||||
> move the version — and a ci-box's template preinstalls the binary at mint,
|
|
||||||
> which would leave `--version` doing nothing on the exact path this command
|
|
||||||
> is for.
|
|
||||||
- `--name <name>` — runner name (default: this host's hostname)
|
|
||||||
- `--labels <csv>` — replaces the default map:
|
|
||||||
`ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm`,
|
|
||||||
so `runs-on: ubuntu-latest` works in a workflow written for GitHub.
|
|
||||||
**Applied at registration only.** Forgejo owns a runner's labels from the
|
|
||||||
moment it registers, so passing `--labels` to a re-run cannot change them —
|
|
||||||
rig says so rather than letting the request evaporate, and changing labels
|
|
||||||
means `remove` then `install` again
|
|
||||||
- `--user <name>` — service user (default: `ci` when it exists, else
|
|
||||||
`forgejo-runner`)
|
|
||||||
|
|
||||||
**There is no `--repo`, and that is the substantive difference.** A Forgejo
|
|
||||||
runner registers to an *instance*; whether it then serves that whole instance,
|
|
||||||
one organisation, or one repository is a property of the **registration
|
|
||||||
token**, which you mint in Forgejo's UI at the scope you want:
|
|
||||||
|
|
||||||
| scope | where the token comes from |
|
|
||||||
|---|---|
|
|
||||||
| instance | Site Administration → Actions → Runners |
|
|
||||||
| organisation | Org → Settings → Actions → Runners |
|
|
||||||
| repository | Repo → Settings → Actions → Runners |
|
|
||||||
|
|
||||||
Pass it via `FORGEJO_RUNNER_TOKEN` or the interactive prompt. It is consumed at
|
|
||||||
registration and never written to disk by rig.
|
|
||||||
|
|
||||||
Convergent **toward `--instance`** — re-running against the instance the box is
|
|
||||||
already on re-uses the binary and skips registration. Pointed at a *different*
|
|
||||||
instance it refuses and names both, for the same reason `rig runner install`
|
|
||||||
refuses a different repo.
|
|
||||||
|
|
||||||
> **`.runner` holds a credential here.** GitHub's names a repository; Forgejo's
|
|
||||||
> holds the runner's own long-lived token. rig installs it `0600` owned by the
|
|
||||||
> runner user and **re-asserts that mode on every converge** — a mode that
|
|
||||||
> drifted leaks the secret silently, since nothing fails and the runner keeps
|
|
||||||
> working. `status` warns when it finds one that has.
|
|
||||||
|
|
||||||
### `rig forgejo-runner status`
|
|
||||||
|
|
||||||
Which instance this box's runner is registered to — instance, name, labels,
|
|
||||||
directory, unit and state. Reads the box only: no token, no network call, and
|
|
||||||
it never prints the registration secret `.runner` holds. Exits 1 when no runner
|
|
||||||
is installed.
|
|
||||||
|
|
||||||
### `rig forgejo-runner remove`
|
|
||||||
|
|
||||||
Stops and disables the service and wipes the local registration. The binary and
|
|
||||||
the user stay, so a later `install` re-registers without downloading anything.
|
|
||||||
|
|
||||||
**Always local-only, and there is no `--local` flag.** Forgejo has no runner
|
|
||||||
deregistration endpoint — no removal token, nothing to hand back — so the box
|
|
||||||
is cleaned and the runner stays listed as offline until you delete it under
|
|
||||||
Actions → Runners. Offering the flag would advertise a server-side alternative
|
|
||||||
that does not exist. For the same reason there is no `forgejo-runner repoint`:
|
|
||||||
a move cannot be one atomic act, so it is `remove` then `install`.
|
|
||||||
|
|
||||||
### Enabling Actions on the Forgejo side
|
|
||||||
|
|
||||||
Two environment variables on the Forgejo service (Coolify → Environment
|
|
||||||
Variables → redeploy). Forgejo maps `FORGEJO__<section>__<KEY>` onto its
|
|
||||||
config, so this survives image upgrades in a way an edited `app.ini` does not:
|
|
||||||
|
|
||||||
```
|
|
||||||
FORGEJO__actions__ENABLED=true
|
|
||||||
FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org
|
|
||||||
```
|
|
||||||
|
|
||||||
> **`DEFAULT_ACTIONS_URL` is a single fallback, and it only governs *steps*.**
|
|
||||||
> It decides where a bare `uses: owner/repo@ref` on a **step** resolves — and
|
|
||||||
> nothing else. Measured on this instance, with a registered runner (#112):
|
|
||||||
>
|
|
||||||
> | reference | count | bare resolves to | verdict |
|
|
||||||
> |---|---|---|---|
|
|
||||||
> | `actions/checkout@v4` (step) | 3 | `code.forgejo.org` | **200** — mirrored, keep bare |
|
|
||||||
> | `heavy-duty/ceremony/actions/…` (step) | 6 | `code.forgejo.org` | **404** — must be absolute |
|
|
||||||
> | `heavy-duty/ceremony/.github/workflows/…` (reusable) | 2 | **this instance** | **200** — keep bare |
|
|
||||||
>
|
|
||||||
> The last row is the one that surprises: a **reusable-workflow** `uses:` never
|
|
||||||
> consults `DEFAULT_ACTIONS_URL` at all. It resolves against the runner's own
|
|
||||||
> instance, so the `release.yml` and `labels.yml` callers already work bare.
|
|
||||||
>
|
|
||||||
> **The ruling is to keep the value above** — `actions/*` are exactly what
|
|
||||||
> `DEFAULT_ACTIONS_URL` is designed to resolve, and `code.forgejo.org` mirrors
|
|
||||||
> them — and to make only the **six** first-party *step* references absolute:
|
|
||||||
>
|
|
||||||
> ```yaml
|
|
||||||
> - uses: https://forgejo.heavyduty.builders/heavy-duty/ceremony/actions/docs-sync@0.3.0
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> The two reusable callers **stay bare**, and `release.yml`'s must: ceremony's
|
|
||||||
> `docs-sync` reads rig's pin out of that line with a grep anchored to the bare
|
|
||||||
> spelling, so absolutising it hides the pin and the guard exits 1. `test/cli.sh`
|
|
||||||
> pins all three shapes.
|
|
||||||
> `rig forgejo-runner` exists to run *your repositories'* workflows and does not
|
|
||||||
> depend on any of it — the value above is correct for those from the start.
|
|
||||||
|
|
||||||
> **A registry served from Forgejo needs one more.** `RIG_TEMPLATES_HOST`
|
|
||||||
> (below) lets the template registry live on any forge, but the mint-time fetch
|
|
||||||
> is **unauthenticated by contract** — box auto-runs `rig bootstrap <role>-box`
|
|
||||||
> at mint, holding no credentials. A Forgejo instance with
|
|
||||||
> `REQUIRE_SIGNIN_VIEW=true` answers **404 for public repos** to anonymous
|
|
||||||
> callers, which is indistinguishable from a wrong ref. So hosting the registry
|
|
||||||
> there also needs:
|
|
||||||
>
|
|
||||||
> ```
|
|
||||||
> FORGEJO__service__REQUIRE_SIGNIN_VIEW=false
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> This affects only the *registry* fetch. `rig forgejo-runner` itself
|
|
||||||
> authenticates with a token and works either way.
|
|
||||||
>
|
|
||||||
> On `forgejo.heavyduty.builders` this is **already set** — verified
|
|
||||||
> 2026-07-27 by resolving a registry from it with no credentials at all.
|
|
||||||
|
|
||||||
### `rig users apply --file <path>`
|
### `rig users apply --file <path>`
|
||||||
|
|
||||||
Converges named operator accounts from a declarative users file — on **every**
|
Converges named operator accounts from a declarative users file — on **every**
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.2-dev
|
0.3.1
|
||||||
|
|
|
||||||
58
bin/rig
58
bin/rig
|
|
@ -87,23 +87,6 @@ commands:
|
||||||
re-register, reusing the binary already on the box. Needs a removal
|
re-register, reusing the binary already on the box. Needs a removal
|
||||||
token for the old repo and a registration token for the new one.
|
token for the old repo and a registration token for the new one.
|
||||||
Run as root.
|
Run as root.
|
||||||
forgejo-runner install --instance <url> [options]
|
|
||||||
Forgejo Actions runner as a systemd service under an unprivileged
|
|
||||||
user — outbound-only, jobs in containers on this box's own dockerd.
|
|
||||||
Its home is a ci-box tenant, where 'rig bootstrap ci-box' already
|
|
||||||
installed that daemon. Registers to an INSTANCE: whether the runner
|
|
||||||
serves that instance, one org or one repo is a property of the
|
|
||||||
registration token, so there is no --repo. Prompts for the token
|
|
||||||
(FORGEJO_RUNNER_TOKEN env overrides). Run as root.
|
|
||||||
forgejo-runner status [--user <name>]
|
|
||||||
Which instance this box's Forgejo runner is registered to: instance,
|
|
||||||
name, labels, unit. Reads the box only — no token, no network call,
|
|
||||||
and never prints the registration secret .runner holds. Run as root.
|
|
||||||
forgejo-runner remove [--user <name>]
|
|
||||||
Take the service down and wipe this box's registration. Always
|
|
||||||
local-only: Forgejo has no deregistration endpoint, so the runner
|
|
||||||
stays listed offline until you delete it in the instance. Needs no
|
|
||||||
token. Run as root.
|
|
||||||
users apply --file <path>
|
users apply --file <path>
|
||||||
Converge named operator accounts from a declarative users file, on
|
Converge named operator accounts from a declarative users file, on
|
||||||
every class: groups by role (admin/rig/box), passwords locked always,
|
every class: groups by role (admin/rig/box), passwords locked always,
|
||||||
|
|
@ -152,12 +135,10 @@ commands:
|
||||||
|
|
||||||
install/upgrade:
|
install/upgrade:
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
||||||
Installs the latest RELEASE from GitHub (the default RIG_HOST).
|
Installs the latest RELEASE (RIG_REF=<tag> pins one, RIG_REF=main
|
||||||
RIG_REF=<tag> pins one, RIG_REF=main tracks the development tree.
|
tracks the development tree). Re-run any time: an installed version
|
||||||
From a Forgejo host: set RIG_HOST to that origin and fetch install.sh
|
converges (no-op), a new one installs side by side at
|
||||||
from <host>/heavy-duty/rig/raw/branch/main/install.sh (#111).
|
<root>/versions/<v> and becomes the default.
|
||||||
Re-run any time: an installed version converges (no-op), a new one
|
|
||||||
installs side by side at <root>/versions/<v> and becomes the default.
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,37 +443,6 @@ case "$cmd" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
forgejo-runner)
|
|
||||||
shift
|
|
||||||
sub="${1:-}"
|
|
||||||
case "$sub" in
|
|
||||||
install)
|
|
||||||
shift
|
|
||||||
exec "$ROOT/commands/forgejo-runner-install.sh" "$@"
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
shift
|
|
||||||
exec "$ROOT/commands/forgejo-runner-status.sh" "$@"
|
|
||||||
;;
|
|
||||||
remove)
|
|
||||||
shift
|
|
||||||
exec "$ROOT/commands/forgejo-runner-remove.sh" "$@"
|
|
||||||
;;
|
|
||||||
repoint)
|
|
||||||
# The GitHub sibling HAS this verb, so an operator will try it. Say why
|
|
||||||
# it cannot exist here rather than printing usage and leaving them to
|
|
||||||
# infer it: 'repoint' is atomic because GitHub lets rig deregister from
|
|
||||||
# the old repo; Forgejo has no such endpoint, so the honest shape is
|
|
||||||
# two acts, and one of them leaves a stale entry behind.
|
|
||||||
printf 'rig: forgejo-runner has no repoint: Forgejo has no deregistration endpoint, so a move cannot be one atomic act. Use "rig forgejo-runner remove" then "rig forgejo-runner install --instance <url>", and delete the stale runner in the old instance under Actions > Runners.\n' >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
usage >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
users)
|
users)
|
||||||
shift
|
shift
|
||||||
sub="${1:-}"
|
sub="${1:-}"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
### Added
|
|
||||||
|
|
||||||
- `rig forgejo-runner install|status|remove` registers a Forgejo Actions runner against an instance, jobs in containers on the box's own dockerd (#109)
|
|
||||||
- The template registry can be served from any forge — `RIG_TEMPLATES_HOST` (#109)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `install.sh` and bootstrap's box fetch take `RIG_HOST` / `BOX_HOST` so a Forgejo origin can serve the install channel (#111)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- rig's six ceremony CI guards resolve on Forgejo (#112)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Ceremony recognizes the Forgejo review panel and both forges' triage actors (#116)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Added
|
|
||||||
|
|
||||||
- The release drill exercises `rig forgejo-runner` beside `rig runner`, so both shipped runner families carry evidence (#129)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The README's install quick start documents the Forgejo channel — `RIG_HOST` shipped in #111 but was documented nowhere (#131)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `rig forgejo-runner status` no longer lets a service's `(active)` stand as proof the runner is fetching jobs (#133)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Forgejo runners installed by rig can start their cache server — `$HOME/.cache` is created and punched through `ProtectHome` (#135)
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `rig forgejo-runner install`, `rig runner install`, `rig users apply` and `rig bootstrap <tenant>` refuse with a named remedy when root's `PATH` carries no `/usr/sbin`, instead of dying on `useradd: command not found` after prompting for a token (#139)
|
|
||||||
- `rig users apply` no longer reports success having silently skipped the sudoers drop-in when `visudo` is off `PATH` (#139)
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
### Added
|
|
||||||
|
|
||||||
- Default Forgejo runner labels include opt-in `ubuntu-latest-full` for the GitHub-parity image (#144)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `ci.yml` installs `shellcheck` when the runner image lacks it, so Forgejo's slim `ubuntu-latest` can run `check` (#144)
|
|
||||||
- Plain `rig forgejo-runner install` warns when recorded labels are a retired rig default, without nagging custom `--labels` (#144)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Added
|
|
||||||
|
|
||||||
- Machine-role templates can declare bootstrap traits and an optional final root install hook (#152)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Added
|
|
||||||
|
|
||||||
- Pinned template registries install with rig and serve default converges offline (#153)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- The netmap tag read is scoped to `Self`: an untagged node next to tagged peers no longer reads a peer's tag, false-refusing `--join login` and false-verifying untagged authkey joins (#160)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Agent tenant boxes ship cron — binary asserted, service enabled and active — so the duty engine can arm its timer (#162)
|
|
||||||
|
|
@ -31,8 +31,6 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
|
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
|
||||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
|
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
|
||||||
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
|
|
||||||
. "$HERE/lib/admin-path.sh" # require_admin_bins
|
|
||||||
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
||||||
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
|
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
|
||||||
# shellcheck source=SCRIPTDIR/lib/manifest.sh
|
# shellcheck source=SCRIPTDIR/lib/manifest.sh
|
||||||
|
|
@ -202,15 +200,6 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
# Root is not enough here either (#139). This mint adds the tenant user to the
|
|
||||||
# docker group with `usermod` far below — AFTER installing docker and node,
|
|
||||||
# which is what makes it the worst-placed of the four call sites: on a
|
|
||||||
# PATH-shorn root it dies mid-convergence with a bare `usermod: command not
|
|
||||||
# found`, having already changed the machine, rather than before touching it.
|
|
||||||
#
|
|
||||||
# Unconditional because the docker block below is unconditional — "every tenant
|
|
||||||
# gets docker" is the stated rule there, so every tenant reaches the usermod.
|
|
||||||
require_admin_bins usermod
|
|
||||||
if [ -r /etc/os-release ]; then
|
if [ -r /etc/os-release ]; then
|
||||||
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
|
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
|
||||||
# sourcing it in the main shell silently clobbers same-named script vars.
|
# sourcing it in the main shell silently clobbers same-named script vars.
|
||||||
|
|
@ -244,27 +233,6 @@ append_line_once() {
|
||||||
chown "$TENANT_USER:$TENANT_GROUP" "$file"
|
chown "$TENANT_USER:$TENANT_GROUP" "$file"
|
||||||
}
|
}
|
||||||
|
|
||||||
# The binary on PATH is not the effective state — an image can ship crontab
|
|
||||||
# with cron.service masked or stopped, and an unarmed timer is exactly the
|
|
||||||
# silent-inert box #162 is about. Converge best-effort, then assert what
|
|
||||||
# systemd actually reports; the assert is the authority. Enabling an
|
|
||||||
# already-enabled unit is a no-op and no path here touches any crontab.
|
|
||||||
# A function so test/cli.sh can lift it verbatim and drive it against a
|
|
||||||
# stubbed systemctl, the drop_incus precedent.
|
|
||||||
converge_cron() {
|
|
||||||
if ! systemctl is-enabled cron >/dev/null 2>&1; then
|
|
||||||
log "cron.service not enabled — converging"
|
|
||||||
systemctl unmask cron >/dev/null 2>&1 || true
|
|
||||||
systemctl enable cron >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
if ! systemctl is-active cron >/dev/null 2>&1; then
|
|
||||||
log "cron.service not active — starting"
|
|
||||||
systemctl start cron >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
systemctl is-enabled cron >/dev/null 2>&1 || die "cron.service is not enabled after converge — the duty engine's timer never fires without it (#162)"
|
|
||||||
systemctl is-active cron >/dev/null 2>&1 || die "cron.service is not active after converge — the duty engine's timer never fires without it (#162)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- packages ----------------------------------------------------------------
|
# --- packages ----------------------------------------------------------------
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
log "installing base packages (tenant ${ROLE})"
|
log "installing base packages (tenant ${ROLE})"
|
||||||
|
|
@ -278,11 +246,8 @@ else
|
||||||
# The shared agent toolbelt the templates carried, plus the definition's
|
# The shared agent toolbelt the templates carried, plus the definition's
|
||||||
# APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a
|
# APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a
|
||||||
# word list, every word already vetted by the parser's package-name gate.
|
# word list, every word already vetted by the parser's package-name gate.
|
||||||
# cron is toolbelt, not a template flavour: every agent tenant exists to
|
|
||||||
# run the cron-driven duty engine, whose unprivileged installer can detect
|
|
||||||
# a missing cron but never apt-get it (#162).
|
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential cron $TPL_APT_EXTRAS
|
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential $TPL_APT_EXTRAS
|
||||||
fi
|
fi
|
||||||
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
|
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
|
||||||
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
|
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
|
||||||
|
|
@ -291,9 +256,6 @@ command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — '
|
||||||
if [ "$ROLE" != "staging-box" ]; then
|
if [ "$ROLE" != "staging-box" ]; then
|
||||||
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
|
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
|
||||||
command -v git >/dev/null 2>&1 || die "git missing after package install"
|
command -v git >/dev/null 2>&1 || die "git missing after package install"
|
||||||
command -v crontab >/dev/null 2>&1 || die "crontab missing after package install — the duty engine arms itself with cron (#162)"
|
|
||||||
# staging-box is exempt with the rest of this block: no agent, no engine.
|
|
||||||
converge_cron
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- docker ------------------------------------------------------------------
|
# --- docker ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -24,28 +24,6 @@ if [ "$runner_installed" -eq 1 ]; then
|
||||||
die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository"
|
die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The same hazard, the other forge (#109): leaving the tailnet under a live
|
|
||||||
# Forgejo runner strands a registration this box can no longer serve, and
|
|
||||||
# Forgejo has no deregistration endpoint — so the ghost it leaves is one
|
|
||||||
# somebody has to delete BY HAND in the instance's admin UI. That makes the
|
|
||||||
# refusal more load-bearing here than for GitHub, not less.
|
|
||||||
#
|
|
||||||
# RIG_FORGEJO_RUNNER_DIR mirrors RIG_RUNNER_DIR above so tests can point this
|
|
||||||
# at a fixture. The glob covers the tenant default (`ci`) and the dedicated
|
|
||||||
# account alike, because both are reachable defaults of `install --user`.
|
|
||||||
forgejo_runner_installed=0
|
|
||||||
if [ -n "${RIG_FORGEJO_RUNNER_DIR:-}" ]; then
|
|
||||||
[ -e "$RIG_FORGEJO_RUNNER_DIR/.runner" ] && forgejo_runner_installed=1
|
|
||||||
else
|
|
||||||
for runner_config in /home/*/forgejo-runner/.runner /root/forgejo-runner/.runner; do
|
|
||||||
[ -e "$runner_config" ] && forgejo_runner_installed=1
|
|
||||||
done
|
|
||||||
[ -e /etc/systemd/system/forgejo-runner.service ] && forgejo_runner_installed=1
|
|
||||||
fi
|
|
||||||
if [ "$forgejo_runner_installed" -eq 1 ]; then
|
|
||||||
die "a Forgejo runner is installed — run 'rig forgejo-runner remove' first so undo does not leave a ghost runner in the instance"
|
|
||||||
fi
|
|
||||||
|
|
||||||
join_by=""
|
join_by=""
|
||||||
while IFS= read -r field; do
|
while IFS= read -r field; do
|
||||||
case "$field" in
|
case "$field" in
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
. "$HERE/lib/users-config.sh" # parse_users_file — the --users PRE-FLIGHT only
|
. "$HERE/lib/users-config.sh" # parse_users_file — the --users PRE-FLIGHT only
|
||||||
# shellcheck source=SCRIPTDIR/lib/manifest.sh
|
# shellcheck source=SCRIPTDIR/lib/manifest.sh
|
||||||
. "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker
|
. "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker
|
||||||
# shellcheck source=SCRIPTDIR/lib/templates.sh
|
|
||||||
. "$HERE/lib/templates.sh" # registry-backed machine-role definitions
|
|
||||||
# The users lib is sourced for validation, never for convergence: `users apply`
|
# The users lib is sourced for validation, never for convergence: `users apply`
|
||||||
# stays the single owner of what a users file DOES to a box (#51). Bootstrap
|
# stays the single owner of what a users file DOES to a box (#51). Bootstrap
|
||||||
# borrows the parser so a typo'd users file is caught in the same breath as a
|
# borrows the parser so a typo'd users file is caught in the same breath as a
|
||||||
|
|
@ -121,7 +119,6 @@ EOF
|
||||||
|
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
ROLE="${1:-}"
|
ROLE="${1:-}"
|
||||||
MACHINE_TEMPLATE_DIR=""
|
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
--undo)
|
--undo)
|
||||||
shift
|
shift
|
||||||
|
|
@ -139,20 +136,7 @@ case "$ROLE" in
|
||||||
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
|
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
"") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
|
"") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
|
||||||
*)
|
*) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
|
||||||
shift
|
|
||||||
templates_resolve || exit 2
|
|
||||||
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
|
|
||||||
MACHINE_TEMPLATE_DIR="$REGISTRY_DIR/$ROLE"
|
|
||||||
if [[ ! "$ROLE" =~ ^[a-z][a-z0-9-]*-server$ ]] \
|
|
||||||
|| [ "$(template_family "$ROLE" 2>/dev/null || true)" != "machine" ] \
|
|
||||||
|| [ ! -f "$MACHINE_TEMPLATE_DIR/template.env" ]; then
|
|
||||||
MACHINE_ROLES="$(templates_machine_roles "$REGISTRY_DIR" | paste -sd'|' -)"
|
|
||||||
[ -n "$MACHINE_ROLES" ] || MACHINE_ROLES="none"
|
|
||||||
die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom; machine roles from $(templates_source_desc): $MACHINE_ROLES; or a '-box' tenant role)" 2
|
|
||||||
fi
|
|
||||||
machine_template_parse_env "$MACHINE_TEMPLATE_DIR/template.env" \
|
|
||||||
|| die "invalid machine role $ROLE from $(templates_source_desc)" 2 ;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Role→traits map — the single place a role's shape is declared (issue #26).
|
# Role→traits map — the single place a role's shape is declared (issue #26).
|
||||||
|
|
@ -172,7 +156,6 @@ case "$ROLE" in
|
||||||
dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
|
dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
|
||||||
workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;;
|
workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;;
|
||||||
custom) ;;
|
custom) ;;
|
||||||
*) ROOT_DOOR="$TPL_ROOT_DOOR" HOST="$TPL_HOST" JOIN="$TPL_JOIN" ;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
|
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
|
||||||
|
|
@ -723,111 +706,22 @@ if [ "$HOST" = "yes" ]; then
|
||||||
BOX_RELEASE=0.9.0
|
BOX_RELEASE=0.9.0
|
||||||
BOX_REPO="${BOX_REPO:-heavy-duty/box}"
|
BOX_REPO="${BOX_REPO:-heavy-duty/box}"
|
||||||
BOX_REF="${BOX_REF:-$BOX_RELEASE}"
|
BOX_REF="${BOX_REF:-$BOX_RELEASE}"
|
||||||
# BOX_HOST: which forge serves box's *installer script* (#111). Parallel to
|
BOX_INSTALL_URL="https://raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh"
|
||||||
# RIG_HOST / RIG_TEMPLATES_HOST. Defaults to RIG_HOST when set, else GitHub,
|
BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 BOX_REF=${BOX_REF} bash"
|
||||||
# so a Forgejo-sourced rig stays Forgejo-native for this fetch without a
|
|
||||||
# second knob — override with BOX_HOST when the two must diverge.
|
|
||||||
# Raw-file grammar:
|
|
||||||
# GitHub raw.githubusercontent.com/<repo>/<ref>/install.sh
|
|
||||||
# Forgejo <host>/<repo>/raw/{tag|branch}/<ref>/install.sh
|
|
||||||
# Forgejo's bare /raw/<ref>/ is branch-first (opposite of /archive/<ref>),
|
|
||||||
# so we never guess kind from spelling: try /raw/tag/ then /raw/branch/
|
|
||||||
# and let the fetch decide (same pin-wins rule as ref_candidate_urls).
|
|
||||||
# SCOPE: this only moves the script fetch. box@0.9.0's installer still
|
|
||||||
# hardcodes GitHub for its own archive — zero-GitHub bootstrap needs a
|
|
||||||
# BOX_HOST knob in heavy-duty/box (tracked separately).
|
|
||||||
BOX_HOST="${BOX_HOST:-${RIG_HOST:-https://github.com}}"
|
|
||||||
BOX_HOST="${BOX_HOST%/}"
|
|
||||||
box_install_urls() {
|
|
||||||
case "$BOX_HOST" in
|
|
||||||
https://github.com|http://github.com|*//github.com)
|
|
||||||
printf 'https://raw.githubusercontent.com/%s/%s/install.sh\n' "$BOX_REPO" "$BOX_REF" ;;
|
|
||||||
*)
|
|
||||||
printf '%s/%s/raw/tag/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF"
|
|
||||||
printf '%s/%s/raw/branch/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
# One pasteable recovery command per candidate URL. Never join candidates
|
|
||||||
# with English prose or shell metacharacters — "curl A | bash; if that
|
|
||||||
# 404s: curl B | bash" is not valid shell (`bash -n` exits 2) and is the
|
|
||||||
# same class of operator-facing failure #111 exists to remove (#125 /
|
|
||||||
# codex REQUEST_CHANGES on !114). Multi-candidate display uses separate
|
|
||||||
# prefixed lines (try: / or:); a single candidate (GitHub default) is a
|
|
||||||
# bare pasteable command — a try: prefix turns paste into a silent no-op
|
|
||||||
# (`try:` is not a command; the pipe's bash still exits 0). After a live
|
|
||||||
# probe succeeds the install loop rewrites BOX_MANUAL to the single URL
|
|
||||||
# that worked. Consumers MUST emit via box_manual_emit — never interpolate
|
|
||||||
# ${BOX_MANUAL} into a single log/warn string (multi-line orphans the or:
|
|
||||||
# line; claude REQUEST_CHANGES on 1c9a245).
|
|
||||||
box_manual_cmd() { # box_manual_cmd <url> — one pasteable install line
|
|
||||||
printf 'curl -fsSL %s | BOX_YES=1 BOX_REF=%s bash\n' "$1" "$BOX_REF"
|
|
||||||
}
|
|
||||||
box_manual_text() {
|
|
||||||
local _n=0 _url _cmd _urls=()
|
|
||||||
while IFS= read -r _url; do
|
|
||||||
[ -n "$_url" ] && _urls+=("$_url")
|
|
||||||
done < <(box_install_urls)
|
|
||||||
# Single candidate: bare command (no try:). Multi: try:/or: lines.
|
|
||||||
if [ "${#_urls[@]}" -le 1 ]; then
|
|
||||||
if [ "${#_urls[@]}" -eq 1 ]; then
|
|
||||||
_cmd="$(box_manual_cmd "${_urls[0]}")"
|
|
||||||
printf '%s' "$_cmd"
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
for _url in "${_urls[@]}"; do
|
|
||||||
_cmd="$(box_manual_cmd "$_url")"
|
|
||||||
_cmd="${_cmd%$'\n'}"
|
|
||||||
_n=$((_n + 1))
|
|
||||||
if [ "$_n" -eq 1 ]; then
|
|
||||||
printf 'try: %s\n' "$_cmd"
|
|
||||||
else
|
|
||||||
printf 'or: %s\n' "$_cmd"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
# Emit BOX_MANUAL one line at a time through log or warn. Never splice the
|
|
||||||
# multi-line value into a prose sentence.
|
|
||||||
box_manual_emit() { # box_manual_emit log|warn
|
|
||||||
local _fn="$1" _line
|
|
||||||
while IFS= read -r _line; do
|
|
||||||
[ -n "$_line" ] && "$_fn" " ${_line}"
|
|
||||||
done <<EOF
|
|
||||||
$BOX_MANUAL
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
BOX_INSTALL_URL="$(box_install_urls | head -n1)"
|
|
||||||
# Newline-separated recovery lines — each command (after optional try:/or:
|
|
||||||
# prefix) is independently pasteable.
|
|
||||||
BOX_MANUAL="$(box_manual_text)"
|
|
||||||
if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
|
if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
|
||||||
log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later:"
|
log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}"
|
||||||
box_manual_emit log
|
|
||||||
elif ! command -v curl >/dev/null 2>&1; then
|
elif ! command -v curl >/dev/null 2>&1; then
|
||||||
warn "curl not found — skipping box install; once curl is present, prepare Incus with:"
|
warn "curl not found — skipping box install; once curl is present, prepare Incus with: ${BOX_MANUAL}"
|
||||||
box_manual_emit warn
|
|
||||||
else
|
else
|
||||||
log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig"
|
log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig"
|
||||||
# BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box
|
# BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box
|
||||||
# builds the Incus stack rather than only dropping the CLI on PATH. Running as
|
# builds the Incus stack rather than only dropping the CLI on PATH. Running as
|
||||||
# root, box installs globally (/opt/box + /usr/local/bin). No-op if box is
|
# root, box installs globally (/opt/box + /usr/local/bin). No-op if box is
|
||||||
# already installed, so re-running bootstrap converges instead of reinstalling.
|
# already installed, so re-running bootstrap converges instead of reinstalling.
|
||||||
# Download and execute are separate so a 404 on /raw/tag/ can fall through
|
# A curl failure (no network) fails the pipe under pipefail and lands in the
|
||||||
# to /raw/branch/ without running a half-fetched body, and so an installer
|
# else — a warning, never an abort: box is the host extra, the OS+tailnet core
|
||||||
# that runs and fails is NOT retried against the next candidate.
|
# is already done.
|
||||||
# A curl failure (no network) lands in the else — a warning, never an
|
if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 BOX_REF="$BOX_REF" bash; then
|
||||||
# abort: box is the host extra, the OS+tailnet core is already done.
|
|
||||||
BOX_SCRIPT="$(mktemp)"
|
|
||||||
BOX_GOT=""
|
|
||||||
while IFS= read -r _box_url; do
|
|
||||||
if curl -fsSL "$_box_url" -o "$BOX_SCRIPT"; then
|
|
||||||
BOX_GOT="$_box_url"
|
|
||||||
BOX_INSTALL_URL="$_box_url"
|
|
||||||
BOX_MANUAL="$(box_manual_cmd "$BOX_INSTALL_URL" | tr -d '\n')"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done < <(box_install_urls)
|
|
||||||
if [ -n "$BOX_GOT" ] && BOX_YES=1 BOX_REF="$BOX_REF" bash "$BOX_SCRIPT"; then
|
|
||||||
# Don't trust the exit code — prove the effective state (issue #12). An
|
# Don't trust the exit code — prove the effective state (issue #12). An
|
||||||
# installer can exit 0 having done less than it claims: box's setup-host
|
# installer can exit 0 having done less than it claims: box's setup-host
|
||||||
# is written for a sudo-capable user, and one of its paths exits 0 after
|
# is written for a sudo-capable user, and one of its paths exits 0 after
|
||||||
|
|
@ -847,18 +741,14 @@ EOF
|
||||||
if box doctor >/dev/null 2>&1; then
|
if box doctor >/dev/null 2>&1; then
|
||||||
log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'"
|
log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'"
|
||||||
else
|
else
|
||||||
warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand:)"
|
warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand: ${BOX_MANUAL})"
|
||||||
box_manual_emit warn
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand:"
|
warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand: ${BOX_MANUAL}"
|
||||||
box_manual_emit warn
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand:"
|
warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand: ${BOX_MANUAL}"
|
||||||
box_manual_emit warn
|
|
||||||
fi
|
fi
|
||||||
rm -f "$BOX_SCRIPT"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -894,17 +784,6 @@ if [ -n "$USERS_FILE" ]; then
|
||||||
"$HERE/users-apply.sh" --file "$USERS_FILE"
|
"$HERE/users-apply.sh" --file "$USERS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A registry machine's optional install is the final convergence phase: after
|
|
||||||
# join, host setup, the marker prerequisites, and operators. It inherits the
|
|
||||||
# caller environment, adds only the selected role, and runs from its definition
|
|
||||||
# directory. Definitions own idempotence, like bootstrap itself.
|
|
||||||
if [ -n "$MACHINE_TEMPLATE_DIR" ] && [ -e "$MACHINE_TEMPLATE_DIR/install.sh" ]; then
|
|
||||||
log "running install hook for ${ROLE} from $(templates_source_desc)"
|
|
||||||
if ! (cd "$MACHINE_TEMPLATE_DIR" && RIG_ROLE="$ROLE" bash ./install.sh); then
|
|
||||||
die "install hook failed for role $ROLE from $(templates_source_desc)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
|
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
|
||||||
if [ "$ROLE" = "control-plane-server" ]; then
|
if [ "$ROLE" = "control-plane-server" ]; then
|
||||||
log "next: rig coolify install --version <pin>"
|
log "next: rig coolify install --version <pin>"
|
||||||
|
|
|
||||||
|
|
@ -1,557 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# rig forgejo-runner install — Forgejo Actions runner as a systemd service
|
|
||||||
# under an unprivileged user. Outbound-only (long-poll to the instance), no
|
|
||||||
# inbound ports. Convergent toward --instance: re-running against the instance
|
|
||||||
# the box is already on leaves it alone; a box registered to a DIFFERENT
|
|
||||||
# instance is refused, never silently restarted on the old one.
|
|
||||||
#
|
|
||||||
# The GitHub sibling (runner-install.sh) refuses Docker outright: it converges
|
|
||||||
# a fleet MACHINE, where `docker` group membership is root-equivalent and the
|
|
||||||
# blast radius is the machine. This command's home is a ci-box TENANT, where
|
|
||||||
# bootstrap-tenant.sh has already installed Docker and added the tenant user to
|
|
||||||
# the group, and where the blast radius is a disposable guest with no inbound
|
|
||||||
# path. Same trade, different machine, opposite answer — which is why this is a
|
|
||||||
# separate command and not a flag on that one.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|
||||||
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
|
|
||||||
. "$HERE/lib/forgejo-runner-config.sh"
|
|
||||||
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
|
|
||||||
. "$HERE/lib/admin-path.sh"
|
|
||||||
|
|
||||||
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
|
|
||||||
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
|
|
||||||
die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|
||||||
|
|
||||||
# The default label map. `runs-on: ubuntu-latest` is what a workflow written
|
|
||||||
# for GitHub says, so it must mean something here or every workflow needs
|
|
||||||
# editing to migrate; catthehacker's image is the act/Forgejo ecosystem's
|
|
||||||
# stand-in for GitHub's runner image. `docker` is the lean second option.
|
|
||||||
#
|
|
||||||
# Both are `docker://` — jobs run in CONTAINERS on the box's own dockerd, not
|
|
||||||
# on the box itself. No docker-in-docker: the guide this came from stacks a
|
|
||||||
# privileged dind sidecar with a plaintext tcp://…:2375 daemon to isolate jobs
|
|
||||||
# from a shared CI server, and inside a box that boundary is already paid for.
|
|
||||||
#
|
|
||||||
# WHY act-22.04 (slim) for ubuntu-latest, not full-22.04 — measured 2026-08-01
|
|
||||||
# against ghcr manifests (#144):
|
|
||||||
# act-22.04: ~0.55 GB compressed / ~2.2 GB on disk — no shellcheck
|
|
||||||
# full-22.04: ~18.67 GB compressed / ~54.5 GB on disk — has shellcheck 0.8.0
|
|
||||||
# A normal box-class ci tenant cannot hold 54.5 GB (typical free space ~34 GB).
|
|
||||||
# So ubuntu-latest stays slim, and workflows must not assume GitHub-image tools
|
|
||||||
# (rig's own ci.yml installs shellcheck when missing). Operators who need the
|
|
||||||
# full tool surface opt in with runs-on: ubuntu-latest-full — that label is
|
|
||||||
# inert until matched, so boxes that never ask pay nothing.
|
|
||||||
DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-latest-full:docker://ghcr.io/catthehacker/ubuntu:full-22.04,docker:docker://node:22-bookworm'
|
|
||||||
|
|
||||||
# labels_are_a_retired_default <recorded>
|
|
||||||
#
|
|
||||||
# True when <recorded> is a past DEFAULT_LABELS value rig has shipped — the
|
|
||||||
# only plain-converge case that should warn about re-registration (#144).
|
|
||||||
# Custom operator maps (drill's Leg 3, any --labels) must return false so a
|
|
||||||
# bare re-run stays quiet. One pattern per past default; add when the string
|
|
||||||
# changes. Extracted and driven by test/cli.sh — a grep pin alone cannot prove
|
|
||||||
# the match is exact.
|
|
||||||
labels_are_a_retired_default() {
|
|
||||||
case "${1:-}" in
|
|
||||||
'ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm') return 0 ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
|
|
||||||
#
|
|
||||||
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
|
|
||||||
# an asset and prove the download matches it. Prints the reason on stderr and
|
|
||||||
# returns 1 on any failure; the caller supplies the refusal in its own voice.
|
|
||||||
#
|
|
||||||
# BYTE-IDENTICAL to the copy in docs/templates/ci-box/install.sh, diffed by
|
|
||||||
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
|
|
||||||
# downloaders cannot share a lib: this one sources commands/lib/, and that one
|
|
||||||
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
|
|
||||||
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
|
|
||||||
# that keeps one policy from becoming two.
|
|
||||||
#
|
|
||||||
# Review !110 is the evidence for why that matters: a fail-open branch lived in
|
|
||||||
# BOTH copies while a grep for "checksum mismatch" passed against both, because
|
|
||||||
# the string it looked for sat right beside the branch it could not see. The
|
|
||||||
# next checksum-policy change must not be able to land in one file only.
|
|
||||||
#
|
|
||||||
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
|
|
||||||
# reasoning ("do not let an upstream layout change break installs") reasons
|
|
||||||
# about the wrong failure: a layout change moves the BINARY url too, so the
|
|
||||||
# download would already have died. "Binary yes, checksum no" is not what a
|
|
||||||
# layout change looks like — it is what an interfered fetch looks like, which
|
|
||||||
# is precisely what a checksum exists to catch. Failing open would hand an
|
|
||||||
# unverified root install to anyone able to block a single URL. There is
|
|
||||||
# deliberately no bypass flag: if upstream really does move its assets, that is
|
|
||||||
# a rig PR editing the URL, not an operator improvising past a security gate.
|
|
||||||
fetch_and_verify_sha256() {
|
|
||||||
local url="$1" file="$2" sumfile="$3" label="$4" want got
|
|
||||||
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
|
|
||||||
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# The published .sha256 names the asset, not our temp path. Compare the
|
|
||||||
# digest itself rather than rewriting the file into sha256sum -c's format:
|
|
||||||
# one comparison, no parsing of a file we did not write.
|
|
||||||
want="$(tr -d '\r' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
|
|
||||||
got="$(sha256sum "$file" | awk '{print $1}')"
|
|
||||||
if [ -z "$want" ]; then
|
|
||||||
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [ "$want" != "$got" ]; then
|
|
||||||
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf 'checksum verified (%s)\n' "$got"
|
|
||||||
}
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
usage: rig forgejo-runner install --instance <url> [options]
|
|
||||||
|
|
||||||
--instance <url> Forgejo instance the runner registers to (required),
|
|
||||||
e.g. https://forgejo.example.com
|
|
||||||
--version <pin> forgejo-runner release to install, e.g. 12.13.2
|
|
||||||
(default: the latest release, resolved at install
|
|
||||||
time). Pin it for a deterministic, auditable install.
|
|
||||||
--name <name> runner name (default: this host's hostname)
|
|
||||||
--labels <csv> runner labels; replaces the default. The default maps
|
|
||||||
ubuntu-latest (slim act image), ubuntu-latest-full
|
|
||||||
(opt-in parity image), and docker onto containers so
|
|
||||||
a workflow written for GitHub runs; full tools need
|
|
||||||
runs-on: ubuntu-latest-full or an install step.
|
|
||||||
--user <name> unprivileged service user (default: the tenant user
|
|
||||||
`ci` when it exists, else forgejo-runner; created if
|
|
||||||
absent; never root)
|
|
||||||
|
|
||||||
Installs forgejo-runner as a systemd service under an unprivileged user. The
|
|
||||||
runner is an agent, not a server: it long-polls the instance outbound and
|
|
||||||
receives jobs down that already-established connection, so it needs ZERO
|
|
||||||
inbound ports.
|
|
||||||
|
|
||||||
Jobs run in Docker containers on this box's own daemon. Inside a ci-box tenant
|
|
||||||
that daemon is already there — `rig bootstrap ci-box` installs it and puts the
|
|
||||||
tenant user in the `docker` group.
|
|
||||||
|
|
||||||
Provide the runner registration token via the FORGEJO_RUNNER_TOKEN env var or
|
|
||||||
the interactive prompt. Get one from the scope you want the runner to serve:
|
|
||||||
instance Site Administration > Actions > Runners > Create new Runner
|
|
||||||
org Org > Settings > Actions > Runners
|
|
||||||
repo Repo > Settings > Actions > Runners
|
|
||||||
The SCOPE IS THE TOKEN'S, not a flag here. It is consumed at registration and
|
|
||||||
never written to disk by rig.
|
|
||||||
|
|
||||||
Convergent toward --instance: re-running against the instance this box is
|
|
||||||
already on re-uses the binary, skips registration, and never asks for a token.
|
|
||||||
A box registered to a DIFFERENT instance is refused — take it off the old one
|
|
||||||
with `rig forgejo-runner remove` first.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
|
||||||
INSTANCE=""
|
|
||||||
VERSION=""
|
|
||||||
RUNNER_NAME="$(hostname)"
|
|
||||||
LABELS="$DEFAULT_LABELS"
|
|
||||||
# Whether --labels was ASKED FOR, distinct from what it resolved to. A rerun
|
|
||||||
# cannot apply labels (Forgejo owns them from registration time), and the
|
|
||||||
# difference between "operator requested a change" and "operator passed
|
|
||||||
# nothing" is what separates a warning worth printing from noise on every
|
|
||||||
# converge.
|
|
||||||
LABELS_EXPLICIT=0
|
|
||||||
RUNNER_USER=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--instance)
|
|
||||||
[ $# -ge 2 ] || die "--instance needs a value" 2
|
|
||||||
INSTANCE="$2"; shift 2 ;;
|
|
||||||
--version)
|
|
||||||
[ $# -ge 2 ] || die "--version needs a value" 2
|
|
||||||
VERSION="$2"; shift 2 ;;
|
|
||||||
--name)
|
|
||||||
[ $# -ge 2 ] || die "--name needs a value" 2
|
|
||||||
RUNNER_NAME="$2"; shift 2 ;;
|
|
||||||
--labels)
|
|
||||||
[ $# -ge 2 ] || die "--labels needs a value" 2
|
|
||||||
LABELS="$2"; LABELS_EXPLICIT=1; shift 2 ;;
|
|
||||||
--user)
|
|
||||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
|
||||||
RUNNER_USER="$2"; shift 2 ;;
|
|
||||||
--repo)
|
|
||||||
# Named, not "unknown flag": everyone arrives here from `rig runner
|
|
||||||
# install --repo`, and the honest answer is that the argument does not
|
|
||||||
# exist on this forge rather than that it is misspelled.
|
|
||||||
[ $# -ge 2 ] && shift
|
|
||||||
die "--repo does not exist here: a Forgejo runner registers to an INSTANCE, and whether it serves that whole instance, one org, or one repo is a property of the registration TOKEN you mint in Forgejo's UI. Pass --instance <url> and mint the token at the scope you want." 2 ;;
|
|
||||||
-h|--help) usage; exit 0 ;;
|
|
||||||
*) die "unknown flag: $1" 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- validation ----------------------------------------------------------
|
|
||||||
[ -n "$INSTANCE" ] || die "--instance <url> is required" 2
|
|
||||||
case "$INSTANCE" in
|
|
||||||
https://*|http://*) ;;
|
|
||||||
*) die "--instance must be a URL with a scheme, e.g. https://forgejo.example.com (got: $INSTANCE)" 2 ;;
|
|
||||||
esac
|
|
||||||
# A path component would be a repo URL — the GitHub habit, and the one mistake
|
|
||||||
# that produces a runner registered somewhere subtly wrong rather than a clean
|
|
||||||
# failure. Refuse it by name.
|
|
||||||
case "${INSTANCE#*://}" in
|
|
||||||
*/*[!/]*) die "--instance takes the instance ROOT, not a repository URL: got ${INSTANCE}. Scope comes from the token, not the URL." 2 ;;
|
|
||||||
esac
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
# The same sane-version charset the resolve-latest path enforces further down.
|
|
||||||
# An explicit pin skipped it entirely and went straight into the download URL,
|
|
||||||
# so a value carrying `/` or `..` was interpolated into a URL PATH rather than
|
|
||||||
# refused. Not a trust boundary — this command is already root, and the operator
|
|
||||||
# typed the flag — but a pin that cannot name a release should fail BY NAME at
|
|
||||||
# parse time, not as an opaque 404 forty lines later. install.sh's
|
|
||||||
# valid_version is the same instinct, and the asymmetry was the whole defect:
|
|
||||||
# the value rig resolves for itself was checked, the one it is handed was not.
|
|
||||||
case "$VERSION" in
|
|
||||||
"") ;; # unset — the latest release is resolved and validated below
|
|
||||||
*[!0-9.]*|.*|*.)
|
|
||||||
die "--version must be a release number like 12.13.2 (got: ${VERSION})" 2 ;;
|
|
||||||
esac
|
|
||||||
[ -n "$LABELS" ] || die "--labels must not be empty" 2
|
|
||||||
|
|
||||||
# The tenant user is the default when it is there: inside a ci-box the runner
|
|
||||||
# IS the tenant, and inventing a second service account beside it would leave
|
|
||||||
# the docker-group membership bootstrap-tenant.sh converged on the wrong user.
|
|
||||||
# Falls back to a dedicated account so this still works on a plain machine.
|
|
||||||
if [ -z "$RUNNER_USER" ]; then
|
|
||||||
if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi
|
|
||||||
fi
|
|
||||||
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
|
|
||||||
|
|
||||||
# --- guards ----------------------------------------------------------------
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
|
||||||
# Root is not enough: the admin binaries must also be reachable (#139). This
|
|
||||||
# sits beside the root check so identity and capability are asserted together,
|
|
||||||
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
|
|
||||||
#
|
|
||||||
# BOTH binaries this command goes on to call: useradd at the user-create below,
|
|
||||||
# usermod at the docker-group add further down. Naming only the first would
|
|
||||||
# still consume the token on a PATH that happened to resolve useradd but not
|
|
||||||
# usermod — the same failure one step later, which is the shape #75 exists to
|
|
||||||
# refuse (a sweep that covers most of its call sites is the hole the next bug
|
|
||||||
# arrives through).
|
|
||||||
require_admin_bins useradd usermod
|
|
||||||
if [ -r /etc/os-release ]; then
|
|
||||||
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
|
|
||||||
# which would clobber this script's $VERSION.
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
|
|
||||||
case "$OS_FAMILY" in
|
|
||||||
*debian*) ;;
|
|
||||||
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
warn "cannot read /etc/os-release; proceeding anyway"
|
|
||||||
fi
|
|
||||||
command -v curl >/dev/null || die "curl is required (run rig bootstrap first)"
|
|
||||||
command -v systemctl >/dev/null || die "systemctl is required — this command installs the runner as a systemd service"
|
|
||||||
|
|
||||||
# --- is this box already registered somewhere else? --------------------------
|
|
||||||
# Before anything is prompted for, downloaded, or started: --instance must
|
|
||||||
# agree with what is already on the box. Everything below treats an existing
|
|
||||||
# .runner as "nothing to do" — right for the instance the box is already on,
|
|
||||||
# silently wrong for any other. See assert_runner_instance.
|
|
||||||
REG_PENDING=1
|
|
||||||
if id -u "$RUNNER_USER" >/dev/null 2>&1; then
|
|
||||||
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
|
|
||||||
RUNNER_DIR="$USER_HOME/forgejo-runner"
|
|
||||||
assert_runner_instance "$RUNNER_DIR" "$INSTANCE" || exit 1
|
|
||||||
if [ -e "$RUNNER_DIR/.runner" ]; then
|
|
||||||
REG_PENDING=0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- registration token — only when registration is actually pending -------
|
|
||||||
if [ "$REG_PENDING" -eq 1 ]; then
|
|
||||||
FORGEJO_RUNNER_TOKEN="${FORGEJO_RUNNER_TOKEN:-}"
|
|
||||||
# Prompt only on a tty: headless, a bare `read` dies under set -e with no
|
|
||||||
# message at all. Refuse loudly, naming the variable.
|
|
||||||
if [ -z "$FORGEJO_RUNNER_TOKEN" ]; then
|
|
||||||
[ -t 0 ] || die "FORGEJO_RUNNER_TOKEN is unset and stdin is not a tty — set FORGEJO_RUNNER_TOKEN to run unattended"
|
|
||||||
read -rsp "forgejo runner registration token: " FORGEJO_RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set FORGEJO_RUNNER_TOKEN to run unattended"; }
|
|
||||||
echo
|
|
||||||
fi
|
|
||||||
[ -n "$FORGEJO_RUNNER_TOKEN" ] || die "empty registration token"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- user --------------------------------------------------------------------
|
|
||||||
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
|
|
||||||
useradd --create-home --shell /bin/bash "$RUNNER_USER"
|
|
||||||
log "created user ${RUNNER_USER}"
|
|
||||||
else
|
|
||||||
log "user exists"
|
|
||||||
fi
|
|
||||||
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
|
|
||||||
RUNNER_GROUP="$(id -gn "$RUNNER_USER")"
|
|
||||||
RUNNER_DIR="$USER_HOME/forgejo-runner"
|
|
||||||
BIN=/usr/local/bin/forgejo-runner
|
|
||||||
|
|
||||||
# The runner talks to dockerd over its socket, so it needs the group. In a
|
|
||||||
# ci-box bootstrap-tenant.sh already did this for the tenant user; on a plain
|
|
||||||
# machine, or for a --user that is not the tenant, it has not.
|
|
||||||
if getent group docker >/dev/null 2>&1; then
|
|
||||||
if id -nG "$RUNNER_USER" | tr ' ' '\n' | grep -qx docker; then
|
|
||||||
log "${RUNNER_USER} already in the docker group"
|
|
||||||
else
|
|
||||||
usermod -aG docker "$RUNNER_USER"
|
|
||||||
log "added ${RUNNER_USER} to the docker group"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "no docker group on this box — jobs using docker:// labels will fail. Inside a ci-box, 'rig bootstrap ci-box' installs docker; elsewhere install it before running jobs."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- download ----------------------------------------------------------------
|
|
||||||
# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each
|
|
||||||
# one. Taking that checksum is nearly free and makes the install auditable —
|
|
||||||
# the same instinct as `coolify install`'s mandatory version pin.
|
|
||||||
#
|
|
||||||
# "Already present" is NOT enough to skip here, and this is where the GitHub
|
|
||||||
# sibling's shape must not be copied. Its skip is justified by "self-update
|
|
||||||
# owns upgrades" — actions/runner updates itself, and GitHub refuses jobs from
|
|
||||||
# stale runners, so freezing it would be pointless. **forgejo-runner does not
|
|
||||||
# self-update.** Nothing else ever moves the version, so a bare presence check
|
|
||||||
# would mean the binary a box first happened to get is the binary it keeps
|
|
||||||
# forever.
|
|
||||||
#
|
|
||||||
# That lands hardest on the path this command is FOR: a ci-box's template
|
|
||||||
# install.sh preinstalls /usr/local/bin/forgejo-runner at mint, so the
|
|
||||||
# executable always exists before an operator ever runs this — and --version,
|
|
||||||
# documented as the deterministic-pin lever, would silently do nothing on
|
|
||||||
# every ci-box in the fleet.
|
|
||||||
#
|
|
||||||
# So: converge toward --version when it is given, exactly as this command
|
|
||||||
# converges toward --instance. A pin is not a trust boundary the way an
|
|
||||||
# instance is (that one refuses), it is an instruction — including downward,
|
|
||||||
# which is what a pin is for. Absent a pin, an existing binary is left alone:
|
|
||||||
# chasing "latest" on every converge would make a re-run an unrequested
|
|
||||||
# upgrade, and convergence must not be a moving target.
|
|
||||||
|
|
||||||
# runner_version_of / runner_download_decision live in the lib, so the rule can
|
|
||||||
# be driven by test/cli.sh without root — see there for the full reasoning.
|
|
||||||
PRESENT_VER=""
|
|
||||||
HAVE_BIN=no
|
|
||||||
if [ -x "$BIN" ]; then
|
|
||||||
HAVE_BIN=yes
|
|
||||||
PRESENT_VER="$(runner_version_of "$BIN")"
|
|
||||||
fi
|
|
||||||
case "$(runner_download_decision "$HAVE_BIN" "$PRESENT_VER" "$VERSION")" in
|
|
||||||
skip)
|
|
||||||
NEED_DOWNLOAD=0
|
|
||||||
if [ -n "$VERSION" ]; then
|
|
||||||
log "forgejo-runner ${VERSION} already installed; skipping download"
|
|
||||||
else
|
|
||||||
log "forgejo-runner ${PRESENT_VER:-(version unreadable)} already present at ${BIN}; skipping download (pass --version <pin> to converge to a specific release)"
|
|
||||||
fi ;;
|
|
||||||
converge)
|
|
||||||
NEED_DOWNLOAD=1
|
|
||||||
log "converging ${BIN}: ${PRESENT_VER:-unreadable} -> ${VERSION} (--version)" ;;
|
|
||||||
*)
|
|
||||||
NEED_DOWNLOAD=1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ "$NEED_DOWNLOAD" -eq 1 ]; then
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64) ARCH="amd64" ;;
|
|
||||||
aarch64) ARCH="arm64" ;;
|
|
||||||
*) die "unsupported arch: $(uname -m)" ;;
|
|
||||||
esac
|
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
# No pin given: resolve the latest release by following the redirect on
|
|
||||||
# the /releases/latest page — no API call, no token, no JSON to parse on
|
|
||||||
# a dependency-free box (install.sh's resolve_latest_tag idiom).
|
|
||||||
LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
|
|
||||||
https://code.forgejo.org/forgejo/runner/releases/latest)" \
|
|
||||||
|| die "could not resolve the latest forgejo-runner release"
|
|
||||||
VERSION="${LATEST_URL##*/}"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
case "$VERSION" in
|
|
||||||
""|*[!0-9.]*) die "could not parse a version from ${LATEST_URL}" ;;
|
|
||||||
esac
|
|
||||||
log "resolved latest forgejo-runner: ${VERSION}"
|
|
||||||
fi
|
|
||||||
ASSET="forgejo-runner-${VERSION}-linux-${ARCH}"
|
|
||||||
URL="https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/${ASSET}"
|
|
||||||
WORKDIR="$(mktemp -d)"
|
|
||||||
cleanup() { rm -rf "$WORKDIR"; }
|
|
||||||
trap cleanup EXIT
|
|
||||||
log "downloading forgejo-runner ${VERSION} (${ARCH})"
|
|
||||||
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
|
||||||
|| die "could not download ${URL}"
|
|
||||||
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|
|
||||||
|| die "refusing to install an unverified ${ASSET} — it lands as root and runs under a systemd unit. See the checksum failure above."
|
|
||||||
|
|
||||||
# Staged beside the target and RENAMED into place, never written over.
|
|
||||||
# Replacing a running executable in place fails with ETXTBSY, and this path
|
|
||||||
# now runs on boxes where the daemon is live (a --version converge). A
|
|
||||||
# rename is atomic and leaves the running process on the old inode until the
|
|
||||||
# restart below picks up the new one.
|
|
||||||
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN.rig-new"
|
|
||||||
mv -f "$BIN.rig-new" "$BIN"
|
|
||||||
log "installed ${BIN}"
|
|
||||||
fi
|
|
||||||
# `|| true` so the refusal BELOW is the one that fires. Under set -euo pipefail
|
|
||||||
# a bare `VAR="$(cmd | head)"` dies at the assignment when cmd exits non-zero,
|
|
||||||
# which is precisely the case this line exists to diagnose — see the lib.
|
|
||||||
INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1 || true)"
|
|
||||||
[ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run"
|
|
||||||
# The converge actually took — asserted, not assumed. A pin that silently did
|
|
||||||
# not land is exactly the failure --version exists to make impossible.
|
|
||||||
if [ -n "$VERSION" ]; then
|
|
||||||
EFFECTIVE_VER="$(runner_version_of "$BIN")"
|
|
||||||
[ "$EFFECTIVE_VER" = "$VERSION" ] \
|
|
||||||
|| die "asked for forgejo-runner ${VERSION} but ${BIN} reports ${EFFECTIVE_VER:-nothing} after install"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- register ----------------------------------------------------------------
|
|
||||||
# UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `register`
|
|
||||||
# and `create-runner-file` carry "(deprecated)" in their help). It is chosen
|
|
||||||
# here anyway, deliberately, and this is the reasoning to revisit when it
|
|
||||||
# finally goes:
|
|
||||||
#
|
|
||||||
# - It still works. `daemon` reads the `.runner` this writes, resolves the
|
|
||||||
# instance from it, and connects — verified against a live instance, where
|
|
||||||
# a planted `.runner` got as far as "Unauthenticated: unregistered runner".
|
|
||||||
# The mechanism is intact; only the credential was fake.
|
|
||||||
# - The successor needs MORE than rig can honestly ask for at this layer:
|
|
||||||
# `daemon --url --uuid --token-url` requires the runner to already exist on
|
|
||||||
# the instance, so the operator would have to create it via API/UI and
|
|
||||||
# carry back a UUID. That is a second, differently-shaped credential dance
|
|
||||||
# for no gain today.
|
|
||||||
# - `register` writes a file `status` can read back. The successor's config
|
|
||||||
# lives in flags on a unit line, where "what is this box registered to" has
|
|
||||||
# no on-disk answer that is not just rig's own copy of what it was told.
|
|
||||||
#
|
|
||||||
# When upstream removes it: the shape becomes `daemon --url/--uuid`, the unit
|
|
||||||
# gains those flags, and forgejo-runner-config.sh's readers move to whatever
|
|
||||||
# holds the UUID. assert_runner_instance's contract survives either way — it
|
|
||||||
# asks about the instance, which both spellings record.
|
|
||||||
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR"
|
|
||||||
# forgejo-runner's cache server writes to $HOME/.cache, which ProtectHome makes
|
|
||||||
# read-only below. Create it HERE, before the unit can reference it: a
|
|
||||||
# ReadWritePaths entry naming a path that does not exist makes systemd refuse
|
|
||||||
# to start the unit at all ("Failed to set up mount namespacing"), which is
|
|
||||||
# worse than the disabled cache it was meant to fix. Measured on a live runner,
|
|
||||||
# 2026-07-31 (#135). Root-owned would fail the same way under User=, so it
|
|
||||||
# carries the runner's own ownership like RUNNER_DIR above.
|
|
||||||
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$USER_HOME/.cache"
|
|
||||||
if [ -e "$RUNNER_DIR/.runner" ]; then
|
|
||||||
log "already registered; skipping registration"
|
|
||||||
# Registration was skipped, so the labels on the instance are the ones it was
|
|
||||||
# registered with — NOT whatever this invocation was passed. Forgejo owns
|
|
||||||
# labels from registration time; a re-run never rewrites them.
|
|
||||||
#
|
|
||||||
# Two warn paths (#144):
|
|
||||||
# EXPLICIT --labels that differs → operator asked and it was not applied.
|
|
||||||
# Plain converge whose recorded labels match a known *retired* default →
|
|
||||||
# rig's default map moved (e.g. added ubuntu-latest-full). Without this
|
|
||||||
# the operator re-runs install, sees "already registered", and believes
|
|
||||||
# they have the new default while Forgejo still holds the old set.
|
|
||||||
#
|
|
||||||
# Do NOT warn on every RECORDED != current default: that fires forever for
|
|
||||||
# any runner the operator deliberately gave custom --labels (drill's Leg 3
|
|
||||||
# registers drill:docker://node:22-bookworm). The old LABELS_EXPLICIT-only
|
|
||||||
# gate existed to avoid that noise; retired-default matching keeps the
|
|
||||||
# silence for intentional maps and still catches silent drift off a past
|
|
||||||
# rig default. Re-register only to pick up labels the old set never had —
|
|
||||||
# nothing matching the recorded set is broken by the map change alone.
|
|
||||||
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
|
|
||||||
RECORDED="$(cat "$RUNNER_DIR/.rig-labels")"
|
|
||||||
if [ "$LABELS_EXPLICIT" -eq 1 ] && [ "$RECORDED" != "$LABELS" ]; then
|
|
||||||
warn "--labels was not applied: this runner is already registered, and Forgejo owns its labels from registration time. It still has: ${RECORDED}. Labels are what 'runs-on' matches, so changing them means re-registering: 'rig forgejo-runner remove' then install again with the labels you want."
|
|
||||||
elif [ "$LABELS_EXPLICIT" -eq 0 ] && labels_are_a_retired_default "$RECORDED"; then
|
|
||||||
warn "this runner was registered with an older rig default label set. The current default adds ubuntu-latest-full (the GitHub-parity image). Labels are fixed at registration, so picking it up means re-registering: 'rig forgejo-runner remove' then install again. Nothing you run today is affected — re-register only if you want the new label."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "registering runner ${RUNNER_NAME} against ${INSTANCE}"
|
|
||||||
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
|
|
||||||
"$BIN" register --no-interactive \
|
|
||||||
--instance "$INSTANCE" --token "$FORGEJO_RUNNER_TOKEN" \
|
|
||||||
--name "$RUNNER_NAME" --labels "$LABELS") \
|
|
||||||
|| die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already"
|
|
||||||
[ -e "$RUNNER_DIR/.runner" ] \
|
|
||||||
|| die "register reported success but wrote no ${RUNNER_DIR}/.runner"
|
|
||||||
# INSIDE the registration branch, where runner-install.sh keeps its copy and
|
|
||||||
# for the same reason: this file records what rig ACTUALLY registered with,
|
|
||||||
# so `status` has something to read back. Writing it unconditionally — as an
|
|
||||||
# earlier draft did — makes a plain re-run stamp this invocation's labels
|
|
||||||
# over a registration that used different ones, and `status` then reports
|
|
||||||
# confidently wrong labels while Forgejo still holds the originals. A
|
|
||||||
# metadata file that can disagree with the thing it describes is worse than
|
|
||||||
# no metadata file.
|
|
||||||
printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels"
|
|
||||||
chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels"
|
|
||||||
fi
|
|
||||||
# EVERY run, registration or not: .runner holds the runner's own long-lived
|
|
||||||
# token, and a mode that drifted leaks it silently. See the lib.
|
|
||||||
forgejo_runner_secure "$RUNNER_DIR" "$RUNNER_USER" "$RUNNER_GROUP"
|
|
||||||
|
|
||||||
# --- service -------------------------------------------------------------
|
|
||||||
# Written by rig rather than shipped by upstream: forgejo-runner has no
|
|
||||||
# svc.sh, so there is no vendor unit to defer to (the GitHub sibling defers to
|
|
||||||
# actions/runner's). Converged like every file rig writes — cmp-guarded, so a
|
|
||||||
# re-run that changes nothing reloads nothing.
|
|
||||||
UNIT=/etc/systemd/system/forgejo-runner.service
|
|
||||||
UNIT_TMP="$(mktemp)"
|
|
||||||
cat > "$UNIT_TMP" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Forgejo Actions runner
|
|
||||||
Documentation=https://forgejo.org/docs/latest/admin/actions/
|
|
||||||
After=network-online.target docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=${RUNNER_USER}
|
|
||||||
WorkingDirectory=${RUNNER_DIR}
|
|
||||||
ExecStart=${BIN} daemon
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
# The runner supervises job containers on this box's docker socket; it is not
|
|
||||||
# a sandbox for them. These keep the DAEMON from being a soft target.
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=read-only
|
|
||||||
ReadWritePaths=${RUNNER_DIR} ${USER_HOME}/.cache
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
if ! cmp -s "$UNIT_TMP" "$UNIT" 2>/dev/null; then
|
|
||||||
install -m 0644 "$UNIT_TMP" "$UNIT"
|
|
||||||
systemctl daemon-reload
|
|
||||||
log "systemd unit written: ${UNIT}"
|
|
||||||
else
|
|
||||||
log "systemd unit already current"
|
|
||||||
fi
|
|
||||||
rm -f "$UNIT_TMP"
|
|
||||||
|
|
||||||
systemctl enable forgejo-runner >/dev/null 2>&1 || die "could not enable forgejo-runner.service"
|
|
||||||
systemctl restart forgejo-runner || die "could not start forgejo-runner.service — see 'journalctl -u forgejo-runner'"
|
|
||||||
|
|
||||||
# Assert the EFFECTIVE state, not systemctl's exit code: a unit that starts and
|
|
||||||
# immediately dies (bad token, unreachable instance) leaves restart succeeding
|
|
||||||
# and the runner absent. Settle briefly, then ask.
|
|
||||||
active=""
|
|
||||||
for _ in 1 2 3 4 5 6; do
|
|
||||||
if systemctl is-active forgejo-runner >/dev/null 2>&1; then active=1; break; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
[ -n "$active" ] || die "forgejo-runner.service is not active after 12s — see 'journalctl -u forgejo-runner' (a bad token or an unreachable instance both land here)"
|
|
||||||
|
|
||||||
log "runner ${RUNNER_NAME} (${INSTALLED_VER}) installed and running"
|
|
||||||
log "labels: ${LABELS}"
|
|
||||||
log "verify it shows Idle under ${INSTANCE} > Site Administration > Actions > Runners"
|
|
||||||
log "this box needs no inbound ports — the runner polls the instance outbound"
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# rig forgejo-runner remove — take the service down and wipe this box's
|
|
||||||
# registration. Convergent: a box with nothing installed exits 0.
|
|
||||||
#
|
|
||||||
# There is no --local flag here, and its absence is the design. The GitHub
|
|
||||||
# sibling offers --local as an ESCAPE HATCH from a real deregistration
|
|
||||||
# handshake (config.sh remove --token, against an endpoint that mints removal
|
|
||||||
# tokens). Forgejo has no such handshake and no such endpoint: local is the
|
|
||||||
# only thing removal can ever be. Shipping the flag would advertise a
|
|
||||||
# server-side alternative that does not exist, and an operator would spend the
|
|
||||||
# afternoon hunting for the token that turns it off.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|
||||||
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
|
|
||||||
. "$HERE/lib/forgejo-runner-config.sh"
|
|
||||||
|
|
||||||
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
|
|
||||||
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
|
|
||||||
die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
usage: rig forgejo-runner remove [--user <name>]
|
|
||||||
|
|
||||||
--user <name> unprivileged service user (default: the tenant user `ci`
|
|
||||||
when it exists, else forgejo-runner)
|
|
||||||
|
|
||||||
Stops and disables the systemd service, then wipes this box's registration.
|
|
||||||
The binary and the user stay put, so a later `rig forgejo-runner install`
|
|
||||||
re-registers without downloading anything.
|
|
||||||
|
|
||||||
Forgejo has no runner deregistration endpoint, so this is always local-only:
|
|
||||||
the box is cleaned, and the runner stays listed as offline in the instance
|
|
||||||
until you delete it under Actions > Runners. No token is needed or asked for.
|
|
||||||
|
|
||||||
Convergent: safe to re-run; a box with no runner installed exits 0.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
|
||||||
RUNNER_USER=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--user)
|
|
||||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
|
||||||
RUNNER_USER="$2"; shift 2 ;;
|
|
||||||
--local)
|
|
||||||
# Named rather than "unknown flag": it is the GitHub sibling's spelling,
|
|
||||||
# and the answer is that removal here is ALWAYS what --local means.
|
|
||||||
die "--local is not a flag here: Forgejo has no deregistration endpoint, so 'rig forgejo-runner remove' is always local-only. Re-run it without the flag, then delete the offline runner in the instance's Actions > Runners." 2 ;;
|
|
||||||
-h|--help) usage; exit 0 ;;
|
|
||||||
*) die "unknown flag: $1" 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$RUNNER_USER" ]; then
|
|
||||||
if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- validation ------------------------------------------------------------
|
|
||||||
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
|
|
||||||
|
|
||||||
# --- guards ----------------------------------------------------------------
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
|
||||||
|
|
||||||
UNIT=/etc/systemd/system/forgejo-runner.service
|
|
||||||
|
|
||||||
# --- nothing to remove? -----------------------------------------------------
|
|
||||||
# The unit is checked INDEPENDENTLY of the user, and that ordering is the whole
|
|
||||||
# point. A missing user used to exit 0 here before the unit was ever looked at,
|
|
||||||
# so a deleted account with a leftover forgejo-runner.service reported "nothing
|
|
||||||
# to remove" and left the unit behind — while the absence-assert at the end,
|
|
||||||
# which never ran, implied removal had been complete. `bootstrap --undo`'s own
|
|
||||||
# unit check would still have caught it, but a verb that claims to have removed
|
|
||||||
# everything must not be the thing that lies about it.
|
|
||||||
RUNNER_DIR=""
|
|
||||||
if id -u "$RUNNER_USER" >/dev/null 2>&1; then
|
|
||||||
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
|
|
||||||
RUNNER_DIR="$USER_HOME/forgejo-runner"
|
|
||||||
else
|
|
||||||
log "no ${RUNNER_USER} user on this box"
|
|
||||||
fi
|
|
||||||
if [ -z "$RUNNER_DIR" ] && [ ! -e "$UNIT" ]; then
|
|
||||||
log "no runner user and no unit on this box; nothing to remove"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [ -n "$RUNNER_DIR" ] && [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$UNIT" ]; then
|
|
||||||
log "no runner registered in ${RUNNER_DIR}; nothing to remove"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
[ -n "$RUNNER_DIR" ] || warn "the ${RUNNER_USER} user is gone but ${UNIT} is still here — removing the orphaned unit"
|
|
||||||
|
|
||||||
INSTANCE=""
|
|
||||||
RUNNER_NAME=""
|
|
||||||
if [ -n "$RUNNER_DIR" ]; then
|
|
||||||
INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")"
|
|
||||||
RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- service ---------------------------------------------------------------
|
|
||||||
# First, in both paths: stopping after the registration is wiped would strand a
|
|
||||||
# running daemon polling with credentials that no longer exist on disk.
|
|
||||||
if [ -e "$UNIT" ]; then
|
|
||||||
log "stopping and disabling forgejo-runner.service"
|
|
||||||
systemctl stop forgejo-runner >/dev/null 2>&1 || true
|
|
||||||
systemctl disable forgejo-runner >/dev/null 2>&1 || true
|
|
||||||
rm -f "$UNIT"
|
|
||||||
systemctl daemon-reload
|
|
||||||
else
|
|
||||||
log "no service installed; skipping"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- registration -----------------------------------------------------------
|
|
||||||
# Every path below is gated on RUNNER_DIR being non-empty. With the user gone
|
|
||||||
# it is "", and an unguarded "$RUNNER_DIR/.rig-labels" would expand to
|
|
||||||
# "/.rig-labels" — an rm at the filesystem root, as root. The repo already
|
|
||||||
# treats this class of expansion as a hazard worth spelling out (`rm -rf
|
|
||||||
# "${ir:?}/versions/$ver"` in bin/rig); same discipline here.
|
|
||||||
if [ -n "$RUNNER_DIR" ]; then
|
|
||||||
if [ -e "$RUNNER_DIR/.runner" ]; then
|
|
||||||
rm -f "$RUNNER_DIR/.runner"
|
|
||||||
log "wiped the local registration"
|
|
||||||
fi
|
|
||||||
rm -f "$RUNNER_DIR/.rig-labels"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# END WITH THE ABSENCE ASSERT: "removed" is a claim, and claims get verified
|
|
||||||
# (the `rig uninstall` precedent).
|
|
||||||
leftover=""
|
|
||||||
[ -n "$RUNNER_DIR" ] && [ -e "$RUNNER_DIR/.runner" ] && leftover="$leftover $RUNNER_DIR/.runner"
|
|
||||||
[ -e "$UNIT" ] && leftover="$leftover $UNIT"
|
|
||||||
if [ -n "$leftover" ]; then
|
|
||||||
printf 'rig-forgejo-runner: remove INCOMPLETE — still present:%s\n' "$leftover" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "runner removed; the binary stays for a future rig forgejo-runner install"
|
|
||||||
warn "the runner${RUNNER_NAME:+ ${RUNNER_NAME}} is still listed as offline in ${INSTANCE:-the instance} — delete it under Actions > Runners. Forgejo has no deregistration endpoint, so rig cannot do this for you."
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# rig forgejo-runner status — what is this box's Forgejo runner registered to?
|
|
||||||
# Read-only: reports what is already on the box. No credential, no network call.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|
||||||
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
|
|
||||||
. "$HERE/lib/forgejo-runner-config.sh"
|
|
||||||
|
|
||||||
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
|
|
||||||
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
|
|
||||||
die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|
||||||
|
|
||||||
# forgejo_runner_liveness_note <systemctl-state> — what `active` does not cover.
|
|
||||||
#
|
|
||||||
# `active` is the strongest health signal this command has, and it proves only
|
|
||||||
# that a process exists — not that the runner is still asking Forgejo for work.
|
|
||||||
# A poller can go quiet while the daemon stays up: measured 2026-07-30 (#129,
|
|
||||||
# #133), a daemon logged "[poller] launched" and never fetched a job dispatched
|
|
||||||
# four minutes later, while a daemon started fresh claimed that same queued task
|
|
||||||
# in one second. Both times it read as a label-mapping bug on the forge, which
|
|
||||||
# is the wrong place to look.
|
|
||||||
#
|
|
||||||
# A FUNCTION rather than an inline `if`, because the state boundary is the part
|
|
||||||
# worth pinning: an absent or inactive unit must say nothing, and a grep over
|
|
||||||
# the source cannot tell the difference (codex/kimi, !134).
|
|
||||||
#
|
|
||||||
# log, not warn: nothing has been DETECTED here. An idle runner with no queued
|
|
||||||
# jobs is silent in exactly the same way a stalled one is, so there is no signal
|
|
||||||
# separating them — a warning on every status run would be crying wolf, and warn
|
|
||||||
# in this file means a drift actually measured (the .runner mode below).
|
|
||||||
forgejo_runner_liveness_note() {
|
|
||||||
[ "${1:-}" = active ] || return 0
|
|
||||||
log " note: 'active' is not proof the runner is fetching jobs — only that the process is up."
|
|
||||||
log " If a job stays queued and its run page says it never started, run"
|
|
||||||
log " 'systemctl restart forgejo-runner' and re-read before suspecting the labels."
|
|
||||||
}
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
usage: rig forgejo-runner status [--user <name>]
|
|
||||||
|
|
||||||
--user <name> unprivileged service user (default: the tenant user `ci`
|
|
||||||
when it exists, else forgejo-runner)
|
|
||||||
|
|
||||||
Prints the Forgejo instance this box's runner is registered to, its runner
|
|
||||||
name, the labels rig recorded when it registered, the install directory, and
|
|
||||||
the systemd unit and its state.
|
|
||||||
|
|
||||||
Reads only the runner's own on-disk config — no token, no network call. The
|
|
||||||
registration secret that config holds is never printed. Exits 1 when no runner
|
|
||||||
is installed.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
|
||||||
RUNNER_USER=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--user)
|
|
||||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
|
||||||
RUNNER_USER="$2"; shift 2 ;;
|
|
||||||
-h|--help) usage; exit 0 ;;
|
|
||||||
*) die "unknown flag: $1" 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$RUNNER_USER" ]; then
|
|
||||||
if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- validation ------------------------------------------------------------
|
|
||||||
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
|
|
||||||
|
|
||||||
# --- guards ----------------------------------------------------------------
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
|
||||||
|
|
||||||
id -u "$RUNNER_USER" >/dev/null 2>&1 \
|
|
||||||
|| die "no runner installed (no ${RUNNER_USER} user on this box)"
|
|
||||||
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
|
|
||||||
RUNNER_DIR="$USER_HOME/forgejo-runner"
|
|
||||||
[ -e "$RUNNER_DIR/.runner" ] \
|
|
||||||
|| die "no runner registered in ${RUNNER_DIR}"
|
|
||||||
|
|
||||||
# --- read the runner's own config -------------------------------------------
|
|
||||||
INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")"
|
|
||||||
RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")"
|
|
||||||
|
|
||||||
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
|
|
||||||
LABELS="$(cat "$RUNNER_DIR/.rig-labels")"
|
|
||||||
else
|
|
||||||
LABELS="(not recorded on this box — see the instance's Actions > Runners)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
UNIT=/etc/systemd/system/forgejo-runner.service
|
|
||||||
if [ -e "$UNIT" ]; then
|
|
||||||
STATE="$(systemctl is-active forgejo-runner 2>/dev/null || true)"
|
|
||||||
SERVICE="forgejo-runner.service (${STATE:-unknown})"
|
|
||||||
else
|
|
||||||
SERVICE="(not installed as a service)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "instance: ${INSTANCE:-unknown}"
|
|
||||||
log "name: ${RUNNER_NAME:-unknown}"
|
|
||||||
log "labels: ${LABELS}"
|
|
||||||
log "dir: ${RUNNER_DIR}"
|
|
||||||
log "service: ${SERVICE}"
|
|
||||||
forgejo_runner_liveness_note "${STATE:-}"
|
|
||||||
|
|
||||||
|
|
||||||
# status is the only command an operator runs when nothing is obviously wrong,
|
|
||||||
# which makes it the right place to notice a mode that drifted. It reports and
|
|
||||||
# does not fix: converging state is `install`'s job, and a read-only verb that
|
|
||||||
# quietly writes is a worse surprise than a loud warning.
|
|
||||||
MODE="$(stat -c '%a' "$RUNNER_DIR/.runner" 2>/dev/null || true)"
|
|
||||||
if [ -n "$MODE" ] && [ "$MODE" != "$FORGEJO_RUNNER_FILE_MODE" ]; then
|
|
||||||
warn ".runner is mode ${MODE}, not ${FORGEJO_RUNNER_FILE_MODE} — it holds this runner's registration secret, and every account on this box can read it. Re-run 'rig forgejo-runner install --instance ${INSTANCE:-<url>}' to converge the mode."
|
|
||||||
fi
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# admin-path.sh — assert the admin binaries are REACHABLE, not merely that we
|
|
||||||
# are root.
|
|
||||||
#
|
|
||||||
# Being uid 0 and being able to find useradd are different facts, and rig
|
|
||||||
# asserted only the first. `su` without `-`, sudo with a sanitised secure_path,
|
|
||||||
# and several container images all hand you a root shell whose PATH carries no
|
|
||||||
# /usr/sbin — which is where useradd, usermod and groupadd live on Debian. The
|
|
||||||
# result was a bare `useradd: command not found` naming a line number inside a
|
|
||||||
# versioned install root, emitted AFTER a registration token had been read off
|
|
||||||
# the operator's terminal (#139).
|
|
||||||
#
|
|
||||||
# Which binaries this covers is measured, not assumed (Debian 13, 2026-08-01):
|
|
||||||
#
|
|
||||||
# useradd usermod groupadd userdel groupdel /usr/sbin package: passwd
|
|
||||||
# visudo /usr/sbin package: sudo
|
|
||||||
# gpasswd /usr/bin package: passwd
|
|
||||||
#
|
|
||||||
# Two consequences worth keeping written down. `gpasswd` is in the same PACKAGE
|
|
||||||
# as useradd but a different DIRECTORY, so it is reachable on a PATH-shorn root
|
|
||||||
# and does not belong in any of these preflights — do not add it for symmetry.
|
|
||||||
# And `visudo` shares the directory but not the package, so its absence has a
|
|
||||||
# second, innocent cause (sudo simply not installed) that the others do not, so
|
|
||||||
# `rig users apply` checks it separately, after the point where that cause is
|
|
||||||
# ruled out. See the comment there.
|
|
||||||
#
|
|
||||||
# (Spelled without the `.sh` on purpose: test/cli.sh pins that exactly one file
|
|
||||||
# under commands/ names that script, to catch a second caller appearing. A
|
|
||||||
# comment is not a caller, but the pin is deliberately blunt and cheap.)
|
|
||||||
#
|
|
||||||
# It REFUSES rather than repairing PATH itself. A command that quietly prepends
|
|
||||||
# /usr/sbin teaches the operator nothing and leaves a misconfigured host
|
|
||||||
# misconfigured; the same reason bootstrap refuses rather than guessing. The
|
|
||||||
# message carries the fix so the refusal costs one paste, not an investigation.
|
|
||||||
|
|
||||||
# require_admin_bins <bin>... — die unless every one resolves on PATH.
|
|
||||||
require_admin_bins() {
|
|
||||||
local missing=() b
|
|
||||||
for b in "$@"; do
|
|
||||||
command -v "$b" >/dev/null 2>&1 || missing+=("$b")
|
|
||||||
done
|
|
||||||
[ "${#missing[@]}" -eq 0 ] && return 0
|
|
||||||
# Names the REMEDY, not this script: the operator typed a `rig ...` command,
|
|
||||||
# and echoing the internal path back at them is the unhelpful half of the
|
|
||||||
# original `useradd: command not found`.
|
|
||||||
die "cannot find ${missing[*]} on PATH — it lives in /usr/sbin, which this root shell does not carry (a 'su' without '-' does this, and so do some container images). Re-run the same rig command with: PATH=/usr/sbin:/sbin:\$PATH"
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Shared reader for the Forgejo runner's own on-disk config ($RUNNER_DIR/.runner).
|
|
||||||
# Sourced by the forgejo-runner-* commands; never executed on its own.
|
|
||||||
#
|
|
||||||
# WHY A SECOND LIB, not an arm inside lib/runner-config.sh: the two files are
|
|
||||||
# different documents making different claims, and the sibling's helpers answer
|
|
||||||
# questions this one cannot ask. GitHub's .runner names a REPOSITORY
|
|
||||||
# (gitHubUrl), so `runner install` converges toward --repo. Forgejo's names an
|
|
||||||
# INSTANCE (address) and nothing else about scope — whether a registration is
|
|
||||||
# instance-wide, org, or single-repo is a property of the TOKEN, decided in
|
|
||||||
# Forgejo's UI before rig ever sees it. There is no repo here to converge
|
|
||||||
# toward, and no way to read one back. Sharing a reader would mean a
|
|
||||||
# gitHubUrl accessor that returns empty forever on one of the two forges.
|
|
||||||
#
|
|
||||||
# json_field is deliberately re-used FROM the sibling rather than copied: a
|
|
||||||
# rig-bootstrapped box has no jq, both files are flat JSON, and one grep/sed
|
|
||||||
# reader for both is the same trade lib/runner-config.sh already argued.
|
|
||||||
|
|
||||||
HERE_FJ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=SCRIPTDIR/runner-config.sh
|
|
||||||
. "$HERE_FJ/runner-config.sh" # json_field
|
|
||||||
|
|
||||||
# THE CREDENTIAL FACT that shapes this whole family: Forgejo's .runner holds
|
|
||||||
# the runner's own long-lived token — the secret it authenticates every poll
|
|
||||||
# with — alongside address/name/labels. GitHub's holds no such thing.
|
|
||||||
#
|
|
||||||
# So the mode is part of the contract, not hygiene: a registration secret
|
|
||||||
# readable by every account on the box is a quiet, permanent credential leak,
|
|
||||||
# and it leaks silently — nothing fails, the runner keeps working. Converge is
|
|
||||||
# the only moment rig can notice a mode that drifted (an operator's editor, a
|
|
||||||
# restore from a tarball that lost modes, a hand-edit to add a label).
|
|
||||||
FORGEJO_RUNNER_FILE_MODE=600
|
|
||||||
|
|
||||||
# forgejo_runner_instance <runner_dir> — the Forgejo instance this box's runner
|
|
||||||
# is registered to, empty when nothing is registered there.
|
|
||||||
forgejo_runner_instance() {
|
|
||||||
[ -e "$1/.runner" ] || return 0
|
|
||||||
json_field "$1/.runner" address
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_runner_name <runner_dir> — the runner's name, empty when unregistered.
|
|
||||||
forgejo_runner_name() {
|
|
||||||
[ -e "$1/.runner" ] || return 0
|
|
||||||
json_field "$1/.runner" name
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_runner_secure <runner_dir> <user> <group> — converge .runner to 0600
|
|
||||||
# owned by the runner user. Called on every install, not only at registration.
|
|
||||||
# Silent on success: this is a mode that should always already be right, and a
|
|
||||||
# line saying so on every converge would train the reader to skip it.
|
|
||||||
forgejo_runner_secure() {
|
|
||||||
local dir="$1" user="$2" group="$3"
|
|
||||||
[ -e "$dir/.runner" ] || return 0
|
|
||||||
chmod "$FORGEJO_RUNNER_FILE_MODE" "$dir/.runner"
|
|
||||||
chown "$user:$group" "$dir/.runner"
|
|
||||||
}
|
|
||||||
|
|
||||||
# runner_version_of <bin> — the bare version number ("12.13.2") the binary
|
|
||||||
# reports, empty when it cannot answer. `forgejo-runner --version` prints
|
|
||||||
# "forgejo-runner version v12.13.2"; the leading v is stripped so this compares
|
|
||||||
# against a --version argument, which has its own v stripped at parse.
|
|
||||||
#
|
|
||||||
# `|| true` for json_field's reason, which bites harder here. Callers run under
|
|
||||||
# `set -euo pipefail`, where a pipeline whose FIRST stage exits non-zero fails
|
|
||||||
# the whole pipeline — and `PRESENT_VER="$(runner_version_of "$BIN")"` is an
|
|
||||||
# assignment, so the script dies AT THAT LINE, with no message. "Empty when it
|
|
||||||
# cannot answer" is only true if this says so out loud.
|
|
||||||
#
|
|
||||||
# That is not a hypothetical shape: the binary is `[ -x ]` but unrunnable
|
|
||||||
# exactly when a ci-box's template preinstall landed a truncated or wrong-arch
|
|
||||||
# download — the one path this command family exists for. Without this, install
|
|
||||||
# exits 1 in silence and the refusal written for that case ("the download
|
|
||||||
# landed but cannot run") is unreachable code.
|
|
||||||
runner_version_of() {
|
|
||||||
"$1" --version 2>/dev/null | head -n1 \
|
|
||||||
| sed -nE 's/.*[Vv]ersion[[:space:]]+v?([0-9][0-9A-Za-z.+-]*).*/\1/p' || true
|
|
||||||
}
|
|
||||||
|
|
||||||
# runner_download_decision <have-binary yes|no> <present-ver> <wanted-ver>
|
|
||||||
# -> "install" | "skip" | "converge"
|
|
||||||
#
|
|
||||||
# A PURE function, and pure on purpose: this is the decision review !110 caught
|
|
||||||
# being wrong, and it was wrong in a way no grep could see. Lifting it out of
|
|
||||||
# the root-only install path is what makes "a pre-existing binary plus
|
|
||||||
# --version" a real test rather than a string match.
|
|
||||||
#
|
|
||||||
# The rule, and why it is not the GitHub sibling's:
|
|
||||||
#
|
|
||||||
# no binary -> install. Nothing to reason about.
|
|
||||||
# binary, no --version -> skip. Chasing "latest" on every converge would make
|
|
||||||
# a plain re-run an unrequested upgrade, and a
|
|
||||||
# convergent verb must not be a moving target.
|
|
||||||
# binary, pin matches -> skip.
|
|
||||||
# binary, pin differs -> CONVERGE, including downward. A pin is an
|
|
||||||
# instruction, not a floor.
|
|
||||||
#
|
|
||||||
# runner-install.sh skips on mere presence because actions/runner SELF-UPDATES,
|
|
||||||
# so its version moves regardless and freezing it would only make GitHub refuse
|
|
||||||
# the runner's jobs. forgejo-runner does not self-update: nothing else ever
|
|
||||||
# moves this version, and a ci-box's template preinstalls the binary at mint —
|
|
||||||
# so mere-presence here would leave --version dead on the one path this whole
|
|
||||||
# command exists to serve.
|
|
||||||
#
|
|
||||||
# An unreadable present version (empty) with a pin asked for falls to
|
|
||||||
# "converge", which is the right direction: a binary that cannot say what it is
|
|
||||||
# should be replaced by one that can.
|
|
||||||
runner_download_decision() {
|
|
||||||
local have="$1" present="$2" want="$3"
|
|
||||||
[ "$have" = yes ] || { printf 'install\n'; return 0; }
|
|
||||||
[ -n "$want" ] || { printf 'skip\n'; return 0; }
|
|
||||||
[ "$present" = "$want" ] && { printf 'skip\n'; return 0; }
|
|
||||||
printf 'converge\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
# assert_runner_instance <runner_dir> <instance-url>
|
|
||||||
#
|
|
||||||
# Returns 0 when the box has no runner, or has one already registered to
|
|
||||||
# <instance-url>: re-running `install` against the instance the box is already
|
|
||||||
# on is real convergence — it re-uses the binary, skips registration, exits 0.
|
|
||||||
#
|
|
||||||
# Returns 1, explaining itself on stderr, when the runner is registered to a
|
|
||||||
# DIFFERENT instance. Skipping *that* is not convergence, it is ignoring the
|
|
||||||
# argument: `install` would skip its registration step, restart the service
|
|
||||||
# against the OLD instance, and report success — leaving the instance you asked
|
|
||||||
# for with no runner and its jobs queued against one that will never come.
|
|
||||||
#
|
|
||||||
# This is assert_runner_repo's reasoning, asked about the axis Forgejo actually
|
|
||||||
# has. There is deliberately no `repoint` sibling: Forgejo has no
|
|
||||||
# deregistration handshake to perform against the old instance, so moving a
|
|
||||||
# runner is `remove` then `install` — two acts that are already honest about
|
|
||||||
# leaving a stale entry behind, rather than one verb pretending to be atomic.
|
|
||||||
assert_runner_instance() {
|
|
||||||
local dir="$1" wanted="$2" current
|
|
||||||
[ -e "$dir/.runner" ] || return 0
|
|
||||||
|
|
||||||
current="$(forgejo_runner_instance "$dir")"
|
|
||||||
|
|
||||||
if [ -z "$current" ]; then
|
|
||||||
printf 'rig-forgejo-runner: ERROR: %s\n' \
|
|
||||||
"${dir}/.runner exists but names no instance — this box's registration cannot
|
|
||||||
be read, so rig cannot tell whether it is already on ${wanted}.
|
|
||||||
Wipe the local registration and install again:
|
|
||||||
rig forgejo-runner remove" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Trailing slashes are a spelling difference, not a different instance:
|
|
||||||
# forgejo-runner records the URL as given, so `--instance https://f.example/`
|
|
||||||
# and `--instance https://f.example` would otherwise read as a move.
|
|
||||||
if [ "${current%/}" = "${wanted%/}" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf 'rig-forgejo-runner: ERROR: %s\n' \
|
|
||||||
"this box's runner is already registered to ${current}, not ${wanted}.
|
|
||||||
install will not move a runner between instances: it would leave the service
|
|
||||||
running against the OLD instance and report success. To move it, take it off
|
|
||||||
the old instance first:
|
|
||||||
rig forgejo-runner remove
|
|
||||||
then install against the new one. Forgejo has no deregistration handshake, so
|
|
||||||
the old entry stays listed until you delete it in that instance's admin UI." >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
@ -14,8 +14,8 @@ json_field() {
|
||||||
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true
|
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# json_string_array <file> <key> — the elements of the array named <key> inside
|
# json_string_array <file> <key> — the elements of the FIRST array named <key>,
|
||||||
# the netmap's `Self` object, one per line; empty when Self or the key is absent.
|
# one per line, empty when the key is absent or the array is empty.
|
||||||
#
|
#
|
||||||
# json_field's sibling for the one shape it cannot read: `.Self.Tags` from
|
# json_field's sibling for the one shape it cannot read: `.Self.Tags` from
|
||||||
# `tailscale status --json` is a JSON array, and bootstrap must assert on it to
|
# `tailscale status --json` is a JSON array, and bootstrap must assert on it to
|
||||||
|
|
@ -23,44 +23,21 @@ json_field() {
|
||||||
# not the tag rig requested. Same grep/sed spirit, same jq-free reason: a
|
# not the tag rig requested. Same grep/sed spirit, same jq-free reason: a
|
||||||
# rig-bootstrapped box has no jq and we will not install one to read one field.
|
# rig-bootstrapped box has no jq and we will not install one to read one field.
|
||||||
#
|
#
|
||||||
# Scoped to Self, NOT document-global. The previous body took the first "Tags"
|
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and an array
|
||||||
# array anywhere in the file and justified it with Self-before-Peer field order.
|
# spans lines — grep is line-oriented and would never see `[ ... ]` whole
|
||||||
# That holds only when Self HAS tags: an untagged Self omits the key entirely
|
# otherwise. `\[[^]]*\]` then captures the first flat array body for <key>
|
||||||
# (Go omitempty), so the match fell through into Peer and returned a PEER's tag
|
# (tag strings never contain `]`, so this is safe); the inner `grep -o` pulls
|
||||||
# — silently inverting both callers on any tailnet with a tagged node (#160).
|
# every quoted token out of it, and `sed 1d` drops the key's own name — which
|
||||||
|
# `"key":[...]` leads with — leaving just the elements.
|
||||||
#
|
#
|
||||||
# Self is brace-counted rather than sliced to the next key: PeerStatus carries a
|
# FIRST array wins by design, and the caller leans on it: `tailscale status
|
||||||
# nested object (Location, a pointer with omitempty), which would end a naive
|
# --json` emits Self before Peer (Go struct field order, stable), so the first
|
||||||
# slice early whenever it is present. Known limit of staying jq-free: a `{` or
|
# "Tags" is the node's OWN, never a peer's. An absent key omits itself entirely
|
||||||
# `}` inside a STRING value within Self would miscount — no PeerStatus string
|
# (Go's omitempty) rather than emitting `[]` — which is exactly the untagged,
|
||||||
# field (hostnames, DNS names, OS, key strings) can contain one, so this is
|
# user-owned node bootstrap must catch. Never fails under `set -e`+pipefail: a
|
||||||
# sound in practice, but it is a real assumption, written down on purpose.
|
# non-match is a fact to test for, like json_field, not a reason to die.
|
||||||
#
|
|
||||||
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and the object
|
|
||||||
# spans lines — awk and grep are line-oriented and would never see it whole
|
|
||||||
# otherwise. `\[[^]]*\]` then captures the flat array body for <key> (tag
|
|
||||||
# strings never contain `]`, so this is safe); the inner `grep -o` pulls every
|
|
||||||
# quoted token out of it, and `sed 1d` drops the key's own name — which
|
|
||||||
# `"key":[...]` leads with — leaving just the elements. Never fails under
|
|
||||||
# `set -e`+pipefail: a non-match is a fact to test for, not a reason to die.
|
|
||||||
json_string_array() {
|
json_string_array() {
|
||||||
local self
|
tr -d '\n' < "$1" 2>/dev/null \
|
||||||
self="$(tr -d '\n' < "$1" 2>/dev/null | awk '
|
|
||||||
{
|
|
||||||
i = index($0, "\"Self\"")
|
|
||||||
if (i == 0) exit
|
|
||||||
s = substr($0, i)
|
|
||||||
j = index(s, "{")
|
|
||||||
if (j == 0) exit
|
|
||||||
depth = 0
|
|
||||||
for (k = j; k <= length(s); k++) {
|
|
||||||
c = substr(s, k, 1)
|
|
||||||
if (c == "{") depth++
|
|
||||||
else if (c == "}") { depth--; if (depth == 0) { print substr(s, j, k - j + 1); exit } }
|
|
||||||
}
|
|
||||||
}')" || true
|
|
||||||
[ -n "$self" ] || return 0
|
|
||||||
printf '%s' "$self" \
|
|
||||||
| grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \
|
| grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \
|
||||||
| head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true
|
| head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@
|
||||||
# <role>/creds.md the per-vendor creds-free paragraph the context
|
# <role>/creds.md the per-vendor creds-free paragraph the context
|
||||||
# renderer splices in
|
# renderer splices in
|
||||||
#
|
#
|
||||||
# THE SOURCE IS FOUR KNOBS plus the installed pin snapshot, precedence
|
# THE SOURCE IS THREE KNOBS, precedence _DIR > _REF > pin:
|
||||||
# _DIR > _REF > snapshot > pin fetch:
|
|
||||||
# RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the
|
# RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the
|
||||||
# offline-test path, and "try a template before it
|
# offline-test path, and "try a template before it
|
||||||
# exists anywhere")
|
# exists anywhere")
|
||||||
|
|
@ -27,10 +26,7 @@
|
||||||
# bootstrap time (the same shape as the rig preinstall)
|
# bootstrap time (the same shape as the rig preinstall)
|
||||||
# RIG_TEMPLATES_REPO which repo that ref lives in (default
|
# RIG_TEMPLATES_REPO which repo that ref lives in (default
|
||||||
# heavy-duty/rig-templates)
|
# heavy-duty/rig-templates)
|
||||||
# RIG_TEMPLATES_HOST which FORGE that repo lives on (default
|
# and, absent both overrides, the PIN below.
|
||||||
# https://github.com) — see templates_archive_urls
|
|
||||||
# and, absent both overrides, the snapshot installed beside this file when it
|
|
||||||
# matches the PIN below, then a live fetch of that pin as the fallback.
|
|
||||||
|
|
||||||
# The default registry ref a mint converges — the BOX_RELEASE discipline
|
# The default registry ref a mint converges — the BOX_RELEASE discipline
|
||||||
# (#103): one line, bumped deliberately by ordinary rig PR after review, so a
|
# (#103): one line, bumped deliberately by ordinary rig PR after review, so a
|
||||||
|
|
@ -43,112 +39,41 @@
|
||||||
# agent tenants ported byte-equivalent from the case arms this PR cut.
|
# agent tenants ported byte-equivalent from the case arms this PR cut.
|
||||||
RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0
|
RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0
|
||||||
|
|
||||||
# The forge the registry lives on. GitHub by default, so every existing caller
|
|
||||||
# is byte-unchanged; overridable because a self-hosted Forgejo is a different
|
|
||||||
# origin AND a different URL grammar (#109).
|
|
||||||
RIG_TEMPLATES_HOST_DEFAULT="https://github.com"
|
|
||||||
|
|
||||||
# templates_archive_urls <host> <repo> <ref> — the source-tarball candidates
|
|
||||||
# for <ref>, in order, one per line. A PURE function (no network, no globals):
|
|
||||||
# test/cli.sh lifts it and drives it against both forges, the resolve_latest_tag
|
|
||||||
# precedent in install.sh.
|
|
||||||
#
|
|
||||||
# The two forges are not URL-compatible, and the difference is not cosmetic:
|
|
||||||
#
|
|
||||||
# GitHub three forms, refs/tags FIRST so a tag always outranks a branch
|
|
||||||
# sharing its name (a pin must win), refs/heads as the fallback
|
|
||||||
# that keeps a branch ref working, then the bare form a commit SHA
|
|
||||||
# downloads through.
|
|
||||||
# Forgejo ONE form. /archive/<ref>.tar.gz resolves tags, branches and SHAs
|
|
||||||
# alike, and the refs/{tags,heads}/ paths are not served at all —
|
|
||||||
# emitting them would mean two guaranteed 404s ahead of every fetch
|
|
||||||
# and a failure message listing URLs that never could have worked.
|
|
||||||
#
|
|
||||||
# Measured against forgejo.heavyduty.builders, not inferred from the docs.
|
|
||||||
templates_archive_urls() {
|
|
||||||
local host="${1%/}" repo="$2" ref="$3"
|
|
||||||
case "$host" in
|
|
||||||
https://github.com|http://github.com|*//github.com)
|
|
||||||
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref"
|
|
||||||
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref"
|
|
||||||
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
|
|
||||||
*)
|
|
||||||
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# The template.env schema. Grammar: blank lines, '#' comments, and
|
# The template.env schema. Grammar: blank lines, '#' comments, and
|
||||||
# KEY="value" — nothing else. Parsed by regex, never sourced.
|
# KEY="value" — nothing else. Parsed by regex, never sourced.
|
||||||
TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE)
|
TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE)
|
||||||
TEMPLATE_KEYS_OPTIONAL=(CLI_SRC NEEDS_NODE APT_EXTRAS)
|
TEMPLATE_KEYS_OPTIONAL=(CLI_SRC NEEDS_NODE APT_EXTRAS)
|
||||||
MACHINE_KEYS_REQUIRED=(ROOT_DOOR HOST JOIN)
|
|
||||||
|
|
||||||
# templates_source_desc — where the resolved registry came from, for error
|
# templates_source_desc — where the resolved registry came from, for error
|
||||||
# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in
|
# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in
|
||||||
# the unknown-role refusal rather than looking like a typo.
|
# the unknown-role refusal rather than looking like a typo.
|
||||||
# The host rides every non-snapshot description: a registry served from the
|
|
||||||
# wrong FORGE fails exactly like a misspelled repo, and naming only the repo
|
|
||||||
# would send the reader hunting for a typo that is not there (#109).
|
|
||||||
templates_source_desc() {
|
templates_source_desc() {
|
||||||
local host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}"
|
|
||||||
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
||||||
printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR"
|
printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR"
|
||||||
elif [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then
|
|
||||||
printf '%s@%s (snapshot)' \
|
|
||||||
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
|
|
||||||
"$RIG_TEMPLATES_PIN"
|
|
||||||
else
|
else
|
||||||
printf '%s/%s@%s%s' \
|
printf '%s@%s%s' \
|
||||||
"${host%/}" \
|
|
||||||
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
|
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
|
||||||
"${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \
|
"${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \
|
||||||
"$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')"
|
"$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# The snapshot path is derived from this library's installed tree. Its
|
# templates_resolve — resolve the three knobs to a LOCAL directory holding
|
||||||
# pin-bearing directory name is the staleness guard: an older snapshot is
|
|
||||||
# invisible after a pin bump. A usable registry has at least one definition;
|
|
||||||
# an empty directory means an interrupted extraction and falls through to the
|
|
||||||
# same live fetch as an absent snapshot.
|
|
||||||
templates_snapshot_dir() {
|
|
||||||
local lib_dir
|
|
||||||
lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
printf '%s/templates@%s' "$(cd "$lib_dir/../.." && pwd)" "$RIG_TEMPLATES_PIN"
|
|
||||||
}
|
|
||||||
|
|
||||||
templates_snapshot_usable() {
|
|
||||||
local snapshot role_env
|
|
||||||
snapshot="$(templates_snapshot_dir)"
|
|
||||||
[ -d "$snapshot" ] || return 1
|
|
||||||
role_env="$(find "$snapshot" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)"
|
|
||||||
[ -n "$role_env" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# templates_resolve — resolve the knobs to a LOCAL directory holding
|
|
||||||
# the registry, left in the REGISTRY_DIR global (a global, not stdout: a
|
# the registry, left in the REGISTRY_DIR global (a global, not stdout: a
|
||||||
# $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP,
|
# $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP,
|
||||||
# the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is
|
# the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is
|
||||||
# used as-is; otherwise the repo@ref tarball is fetched and extracted under
|
# used as-is; otherwise the repo@ref tarball is fetched and extracted under
|
||||||
# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs come from
|
# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs follow install.sh's
|
||||||
# templates_archive_urls, which is forge-aware — see there for why the two
|
# precedence — a tag outranks a branch that shares its name — plus the bare
|
||||||
# forges cannot share one list.
|
# archive/<ref> form, which is how a commit-SHA pin (the default) downloads.
|
||||||
#
|
|
||||||
# Failure lists every URL tried: the fetch is unauthenticated by contract
|
# Failure lists every URL tried: the fetch is unauthenticated by contract
|
||||||
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
|
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
|
||||||
# and the ref real" is the whole diagnosis.
|
# and the ref real" is the whole diagnosis.
|
||||||
#
|
|
||||||
# On a SELF-HOSTED forge there is a third way to fail that reads exactly like
|
|
||||||
# the other two, so the refusal names it: an instance with
|
|
||||||
# REQUIRE_SIGNIN_VIEW=true serves 404 for public repos to anonymous callers —
|
|
||||||
# the same status a wrong ref gets. A mint holds no credentials and never
|
|
||||||
# will, so such an instance cannot host a registry until it serves public
|
|
||||||
# repos anonymously (#109).
|
|
||||||
TEMPLATES_TMP=""
|
TEMPLATES_TMP=""
|
||||||
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
|
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
|
||||||
REGISTRY_DIR=""
|
REGISTRY_DIR=""
|
||||||
templates_resolve() {
|
templates_resolve() {
|
||||||
local repo ref host url got=""
|
local repo ref url got=""
|
||||||
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
||||||
[ -d "$RIG_TEMPLATES_DIR" ] || {
|
[ -d "$RIG_TEMPLATES_DIR" ] || {
|
||||||
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
|
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
|
||||||
|
|
@ -157,38 +82,31 @@ templates_resolve() {
|
||||||
REGISTRY_DIR="$RIG_TEMPLATES_DIR"
|
REGISTRY_DIR="$RIG_TEMPLATES_DIR"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then
|
|
||||||
REGISTRY_DIR="$(templates_snapshot_dir)"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
||||||
ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}"
|
ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}"
|
||||||
host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}"
|
|
||||||
command -v curl >/dev/null 2>&1 || { printf 'curl is required to fetch the template registry\n' >&2; return 1; }
|
command -v curl >/dev/null 2>&1 || { printf 'curl is required to fetch the template registry\n' >&2; return 1; }
|
||||||
command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; }
|
command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; }
|
||||||
TEMPLATES_TMP="$(mktemp -d)"
|
TEMPLATES_TMP="$(mktemp -d)"
|
||||||
while IFS= read -r url; do
|
for url in \
|
||||||
|
"https://github.com/$repo/archive/refs/tags/$ref.tar.gz" \
|
||||||
|
"https://github.com/$repo/archive/refs/heads/$ref.tar.gz" \
|
||||||
|
"https://github.com/$repo/archive/$ref.tar.gz"; do
|
||||||
if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
||||||
done < <(templates_archive_urls "$host" "$repo" "$ref")
|
done
|
||||||
if [ -z "$got" ]; then
|
if [ -z "$got" ]; then
|
||||||
printf 'cannot fetch the template registry %s/%s@%s — tried:\n' "${host%/}" "$repo" "$ref" >&2
|
printf 'cannot fetch the template registry %s@%s — tried:\n' "$repo" "$ref" >&2
|
||||||
templates_archive_urls "$host" "$repo" "$ref" | sed 's/^/ /' >&2
|
printf ' https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$repo" "$ref" >&2
|
||||||
|
printf ' https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$repo" "$ref" >&2
|
||||||
|
printf ' https://github.com/%s/archive/%s.tar.gz\n' "$repo" "$ref" >&2
|
||||||
printf 'the fetch is unauthenticated by contract (a mint holds no credentials): the repo must be public and the ref must exist. RIG_TEMPLATES_DIR=<dir> bypasses the fetch.\n' >&2
|
printf 'the fetch is unauthenticated by contract (a mint holds no credentials): the repo must be public and the ref must exist. RIG_TEMPLATES_DIR=<dir> bypasses the fetch.\n' >&2
|
||||||
case "${host%/}" in
|
|
||||||
https://github.com|http://github.com) ;;
|
|
||||||
*) printf 'on a self-hosted forge, check the instance serves PUBLIC repos to anonymous callers too: Forgejo with REQUIRE_SIGNIN_VIEW=true answers 404 for a public repo, which is indistinguishable from a wrong ref above (set FORGEJO__service__REQUIRE_SIGNIN_VIEW=false).\n' >&2 ;;
|
|
||||||
esac
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || {
|
tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || {
|
||||||
printf 'cannot extract the registry tarball from %s\n' "$got" >&2
|
printf 'cannot extract the registry tarball from %s\n' "$got" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
# A source archive holds exactly one top-level directory — assert that
|
# A GitHub archive holds exactly one top-level directory (<repo>-<ref>,
|
||||||
# SHAPE, never the name, because the name is the forge's choice and the two
|
# slashes flattened) — assert that shape instead of assuming the name.
|
||||||
# disagree: GitHub writes <repo>-<ref> (slashes flattened), Forgejo writes
|
|
||||||
# bare <repo>. Globbing for the shape is what makes this line survive a
|
|
||||||
# forge swap untouched.
|
|
||||||
set -- "$TEMPLATES_TMP"/*/
|
set -- "$TEMPLATES_TMP"/*/
|
||||||
{ [ $# -eq 1 ] && [ -d "$1" ]; } || {
|
{ [ $# -eq 1 ] && [ -d "$1" ]; } || {
|
||||||
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
|
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
|
||||||
|
|
@ -210,26 +128,6 @@ templates_roles() {
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# template_family <role> — directory names are the registry's family tag.
|
|
||||||
# workstation is the one intentional suffix-less machine role (#152 / epic D5).
|
|
||||||
template_family() {
|
|
||||||
case "$1" in
|
|
||||||
*-box) printf 'tenant\n' ;;
|
|
||||||
*-server|workstation) printf 'machine\n' ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# templates_machine_roles <registry-dir> — only machine definitions, for the
|
|
||||||
# machine bootstrap's unknown-role refusal.
|
|
||||||
templates_machine_roles() {
|
|
||||||
local role
|
|
||||||
while IFS= read -r role; do
|
|
||||||
[ "$(template_family "$role" 2>/dev/null || true)" = "machine" ] || continue
|
|
||||||
printf '%s\n' "$role"
|
|
||||||
done < <(templates_roles "$1")
|
|
||||||
}
|
|
||||||
|
|
||||||
# template_parse_env <template.env> — parse against the allowlist. Sets
|
# template_parse_env <template.env> — parse against the allowlist. Sets
|
||||||
# TPL_USER, TPL_CONTEXT_PATH, TPL_CLI_NAME, TPL_CLI_SRC, TPL_PATH_LINE,
|
# TPL_USER, TPL_CONTEXT_PATH, TPL_CLI_NAME, TPL_CLI_SRC, TPL_PATH_LINE,
|
||||||
# TPL_NEEDS_NODE (default no), TPL_APT_EXTRAS. Every refusal names the
|
# TPL_NEEDS_NODE (default no), TPL_APT_EXTRAS. Every refusal names the
|
||||||
|
|
@ -308,62 +206,6 @@ template_parse_env() {
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# machine_template_parse_env <template.env> — the fleet-machine traits schema.
|
|
||||||
# The globals match bootstrap's table columns so a definition becomes a table
|
|
||||||
# row without changing any downstream trait behavior.
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
machine_template_parse_env() {
|
|
||||||
local file="$1" line key val n=0 seen=" " k ok
|
|
||||||
TPL_ROOT_DOOR="" TPL_HOST="" TPL_JOIN=""
|
|
||||||
[ -f "$file" ] || { printf 'template.env missing: %s\n' "$file" >&2; return 1; }
|
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
|
||||||
n=$((n+1))
|
|
||||||
case "$line" in ''|'#'*) continue ;; esac
|
|
||||||
if [[ ! "$line" =~ ^([A-Z_]+)=\"(.*)\"$ ]]; then
|
|
||||||
printf 'template.env:%d: not KEY="value": %s\n' "$n" "$line" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}"
|
|
||||||
ok=""
|
|
||||||
for k in "${MACHINE_KEYS_REQUIRED[@]}"; do
|
|
||||||
[ "$key" = "$k" ] && ok=1
|
|
||||||
done
|
|
||||||
[ -n "$ok" ] || {
|
|
||||||
printf 'template.env:%d: unknown key: %s (allowed: %s)\n' \
|
|
||||||
"$n" "$key" "${MACHINE_KEYS_REQUIRED[*]}" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
case "$seen" in *" $key "*)
|
|
||||||
printf 'template.env:%d: duplicate key: %s\n' "$n" "$key" >&2
|
|
||||||
return 1 ;;
|
|
||||||
esac
|
|
||||||
seen="$seen$key "
|
|
||||||
case "$key" in
|
|
||||||
ROOT_DOOR) TPL_ROOT_DOOR="$val" ;;
|
|
||||||
HOST) TPL_HOST="$val" ;;
|
|
||||||
JOIN) TPL_JOIN="$val" ;;
|
|
||||||
esac
|
|
||||||
done < "$file"
|
|
||||||
for k in "${MACHINE_KEYS_REQUIRED[@]}"; do
|
|
||||||
case "$seen" in *" $k "*) ;; *)
|
|
||||||
printf 'template.env: missing required key: %s\n' "$k" >&2
|
|
||||||
return 1 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
case "$TPL_ROOT_DOOR" in
|
|
||||||
open|closed) ;;
|
|
||||||
*) printf 'template.env: ROOT_DOOR: want open or closed, got: %s\n' "$TPL_ROOT_DOOR" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
case "$TPL_HOST" in
|
|
||||||
yes|no) ;;
|
|
||||||
*) printf 'template.env: HOST: want yes or no, got: %s\n' "$TPL_HOST" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
case "$TPL_JOIN" in
|
|
||||||
authkey|login) ;;
|
|
||||||
*) printf 'template.env: JOIN: want authkey or login, got: %s\n' "$TPL_JOIN" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# render_tenant_context <role> <creds.md> — the agent-context file's
|
# render_tenant_context <role> <creds.md> — the agent-context file's
|
||||||
# content, on stdout: the one file every agent reads before touching
|
# content, on stdout: the one file every agent reads before touching
|
||||||
# anything. The skeleton is MECHANISM and lives here once — the box#80 guard
|
# anything. The skeleton is MECHANISM and lives here once — the box#80 guard
|
||||||
|
|
@ -406,15 +248,13 @@ EOF
|
||||||
# protects the registry, the mint-time parse protects a mint served through
|
# protects the registry, the mint-time parse protects a mint served through
|
||||||
# RIG_TEMPLATES_REPO/_DIR that CI never saw.
|
# RIG_TEMPLATES_REPO/_DIR that CI never saw.
|
||||||
template_lint() {
|
template_lint() {
|
||||||
local dir="${1%/}" role family
|
local dir="${1%/}" role
|
||||||
role="$(basename "$dir")"
|
role="$(basename "$dir")"
|
||||||
[ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; }
|
[ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; }
|
||||||
family="$(template_family "$role" 2>/dev/null || true)"
|
case "$role" in
|
||||||
[ -n "$family" ] || {
|
*-box|*-server) ;;
|
||||||
printf '%s: role directories carry a family suffix (-box for box tenants, -server for fleet machines — rig#76; workstation is #152 machine carve-out)\n' "$role" >&2
|
*) printf '%s: role directories carry a family suffix (-box for box tenants, -server for fleet machines — rig#76)\n' "$role" >&2; return 1 ;;
|
||||||
return 1
|
esac
|
||||||
}
|
|
||||||
if [ "$family" = "tenant" ]; then
|
|
||||||
template_parse_env "$dir/template.env" || return 1
|
template_parse_env "$dir/template.env" || return 1
|
||||||
[ -s "$dir/install.sh" ] \
|
[ -s "$dir/install.sh" ] \
|
||||||
|| { printf '%s: install.sh missing or empty\n' "$role" >&2; return 1; }
|
|| { printf '%s: install.sh missing or empty\n' "$role" >&2; return 1; }
|
||||||
|
|
@ -422,16 +262,5 @@ template_lint() {
|
||||||
|| { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; }
|
|| { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; }
|
||||||
grep -q '[^[:space:]]' "$dir/creds.md" 2>/dev/null \
|
grep -q '[^[:space:]]' "$dir/creds.md" 2>/dev/null \
|
||||||
|| { printf '%s: creds.md missing or blank (the context renderer splices it in — a blank paragraph would ship a context file with a hole)\n' "$role" >&2; return 1; }
|
|| { printf '%s: creds.md missing or blank (the context renderer splices it in — a blank paragraph would ship a context file with a hole)\n' "$role" >&2; return 1; }
|
||||||
else
|
|
||||||
machine_template_parse_env "$dir/template.env" || return 1
|
|
||||||
[ ! -e "$dir/creds.md" ] \
|
|
||||||
|| { printf '%s: creds.md is not allowed for machine roles (machines render no tenant context)\n' "$role" >&2; return 1; }
|
|
||||||
if [ -e "$dir/install.sh" ]; then
|
|
||||||
[ -s "$dir/install.sh" ] \
|
|
||||||
|| { printf '%s: install.sh is empty\n' "$role" >&2; return 1; }
|
|
||||||
head -n1 "$dir/install.sh" | grep -q '^#!' \
|
|
||||||
|| { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; }
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ set -euo pipefail
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
|
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
|
||||||
. "$HERE/lib/runner-config.sh"
|
. "$HERE/lib/runner-config.sh"
|
||||||
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
|
|
||||||
. "$HERE/lib/admin-path.sh"
|
|
||||||
|
|
||||||
log() { printf 'rig-runner: %s\n' "$*"; }
|
log() { printf 'rig-runner: %s\n' "$*"; }
|
||||||
warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; }
|
||||||
|
|
@ -87,10 +85,6 @@ VERSION="${VERSION#v}"
|
||||||
|
|
||||||
# --- guards ----------------------------------------------------------------
|
# --- guards ----------------------------------------------------------------
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
# Root is not enough: the admin binaries must also be reachable (#139). This
|
|
||||||
# sits beside the root check so identity and capability are asserted together,
|
|
||||||
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
|
|
||||||
require_admin_bins useradd
|
|
||||||
if [ -r /etc/os-release ]; then
|
if [ -r /etc/os-release ]; then
|
||||||
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
|
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
|
||||||
# which would clobber this script's $VERSION.
|
# which would clobber this script's $VERSION.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# rig template-lint <role-dir>... — is this a valid role definition?
|
# rig template-lint <role-dir>... — is this a valid tenant-role definition?
|
||||||
#
|
#
|
||||||
# rig defines what a valid template is (the schema lives in
|
# rig defines what a valid template is (the schema lives in
|
||||||
# lib/templates.sh, beside the mint-time parser that enforces it); the
|
# lib/templates.sh, beside the mint-time parser that enforces it); the
|
||||||
|
|
@ -23,15 +23,12 @@ usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: rig template-lint <role-dir>...
|
usage: rig template-lint <role-dir>...
|
||||||
|
|
||||||
Validate role definitions (the heavy-duty/rig-templates shape).
|
Validate tenant-role definitions (the heavy-duty/rig-templates shape):
|
||||||
|
each <role-dir> must carry a family-suffixed name (rig#76), a template.env
|
||||||
Tenant roles use a *-box directory, tenant template.env schema, a shebang
|
that parses against the allowlist (KEY="value" only — the file is data,
|
||||||
install.sh, and non-blank creds.md. Machine roles use a *-server directory
|
never sourced), an install.sh with a shebang, and a non-blank creds.md.
|
||||||
(or exact name workstation), the ROOT_DOOR/HOST/JOIN schema, no creds.md,
|
Every refusal names the failing key or file. Exits non-zero if any
|
||||||
and an optional install.sh which must be non-empty and carry a shebang.
|
definition fails; nothing is written.
|
||||||
template.env is parsed as KEY="value" data and never sourced. Every refusal
|
|
||||||
names the failing key or file. Exits non-zero if any definition fails;
|
|
||||||
nothing is written.
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ set -euo pipefail
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
. "$HERE/lib/users-config.sh"
|
. "$HERE/lib/users-config.sh"
|
||||||
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
|
|
||||||
. "$HERE/lib/admin-path.sh"
|
|
||||||
|
|
||||||
log() { printf 'rig-users: %s\n' "$*"; }
|
log() { printf 'rig-users: %s\n' "$*"; }
|
||||||
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
||||||
|
|
@ -138,10 +136,6 @@ done <<< "$PARSED"
|
||||||
|
|
||||||
# --- guards ------------------------------------------------------------------
|
# --- guards ------------------------------------------------------------------
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
# Root is not enough: the admin binaries must also be reachable (#139). This
|
|
||||||
# sits beside the root check so identity and capability are asserted together,
|
|
||||||
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
|
|
||||||
require_admin_bins useradd usermod groupadd
|
|
||||||
|
|
||||||
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
|
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
|
||||||
# is binary-scoped but not argument-scoped, so without this gate a rig-role
|
# is binary-scoped but not argument-scoped, so without this gate a rig-role
|
||||||
|
|
@ -201,26 +195,6 @@ if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
|
||||||
CHANGED=1
|
CHANGED=1
|
||||||
fi
|
fi
|
||||||
# visudo is checked HERE and not beside the root check, because until the block
|
|
||||||
# above has run there is a legitimate reason for it to be absent: sudo is not
|
|
||||||
# installed yet, and apply is what installs it. Above, a missing visudo would
|
|
||||||
# be indistinguishable from that, so the refusal would fire on a healthy box.
|
|
||||||
#
|
|
||||||
# Below, it is unambiguous. sudo is present, so `visudo` missing means only one
|
|
||||||
# thing: /usr/sbin is off PATH. And it MUST refuse here rather than be left to
|
|
||||||
# the sudoers block further down, because that block asks `command -v visudo`
|
|
||||||
# and treats false as "no sudo on the box means no role needed it" — which on a
|
|
||||||
# PATH-shorn root is FALSE TWICE. A role does need it, sudo is installed, and
|
|
||||||
# apply would finish reporting success having silently never written the
|
|
||||||
# sudoers drop-in: the users get their roles and not the escalation the roles
|
|
||||||
# are FOR. That is the failure this whole issue is about (a wrong effective
|
|
||||||
# state reported as success, #12), in its quietest form — the other three sites
|
|
||||||
# at least crash. Refusing before the first mutation is what keeps it loud.
|
|
||||||
#
|
|
||||||
# It sits before `groupadd` below, so nothing has been converged when it fires.
|
|
||||||
if [ "$NEED_SUDO" -eq 1 ]; then
|
|
||||||
require_admin_bins visudo
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- groups ------------------------------------------------------------------
|
# --- groups ------------------------------------------------------------------
|
||||||
groupadd -f rig-admin
|
groupadd -f rig-admin
|
||||||
|
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
# Forgejo-native CI: a `ci-box` tenant and a `forgejo-runner` command family
|
|
||||||
|
|
||||||
Status: proposed (#109)
|
|
||||||
Date: 2026-07-27
|
|
||||||
|
|
||||||
## The shape of the thing
|
|
||||||
|
|
||||||
A fleet machine hosts boxes. One of those boxes is the CI box. The Forgejo
|
|
||||||
runner lives inside it, and CI jobs run in containers on that box's own
|
|
||||||
dockerd.
|
|
||||||
|
|
||||||
```
|
|
||||||
+-----------------------------+ outbound HTTPS (long-poll)
|
|
||||||
| Forgejo (Coolify) | <---------------------------------+
|
|
||||||
| forgejo.heavyduty.builders | |
|
|
||||||
+-----------------------------+ |
|
|
||||||
+------------------------------+---+
|
|
||||||
No inbound path to the box. | staging-server (host=yes) |
|
|
||||||
The runner polls out. | +---------------------------+ |
|
|
||||||
| | ci-box (tenant) | |
|
|
||||||
| | forgejo-runner (systemd)| |
|
|
||||||
| | dockerd -> job containers| |
|
|
||||||
| +---------------------------+ |
|
|
||||||
+----------------------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
Three pieces, in two repos.
|
|
||||||
|
|
||||||
## 1. A forge-agnostic registry fetch (`commands/lib/templates.sh`)
|
|
||||||
|
|
||||||
`templates_resolve` builds three candidate URLs, all on `github.com`. A
|
|
||||||
registry hosted anywhere else cannot be fetched.
|
|
||||||
|
|
||||||
The fix is a fourth knob beside `_DIR` / `_REF` / `_REPO`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
RIG_TEMPLATES_HOST the forge origin (default https://github.com)
|
|
||||||
```
|
|
||||||
|
|
||||||
The candidate list becomes the host's fact rather than a constant, because the
|
|
||||||
two forges genuinely differ:
|
|
||||||
|
|
||||||
| | GitHub | Forgejo |
|
|
||||||
|---|---|---|
|
|
||||||
| Candidates | `archive/refs/tags/<ref>.tar.gz`, `archive/refs/heads/<ref>.tar.gz`, `archive/<ref>.tar.gz` | `archive/<ref>.tar.gz` — one form, which resolves tags, branches and SHAs alike |
|
|
||||||
| Archive top-level dir | `<repo>-<ref>` | `<repo>` |
|
|
||||||
|
|
||||||
Both were measured against `forgejo.heavyduty.builders`, not assumed.
|
|
||||||
|
|
||||||
The existing "exactly one top-level directory" assert survives untouched — it
|
|
||||||
globs `*/` rather than reconstructing the name, so the differing directory name
|
|
||||||
costs nothing. Its **comment** was wrong for Forgejo and is corrected.
|
|
||||||
|
|
||||||
`templates_source_desc` grows the host, so a misconfigured origin shows up in
|
|
||||||
the unknown-role refusal instead of reading like a typo. The GitHub default
|
|
||||||
means every existing caller behaves exactly as before.
|
|
||||||
|
|
||||||
`install.sh`'s `snapshot_templates` duplicates the same candidate list for its
|
|
||||||
install-time cache. It gets the same knob, from the same environment variable,
|
|
||||||
so the snapshot and the live fetch cannot disagree about where the registry is.
|
|
||||||
|
|
||||||
### The blocker this exposed — since cleared
|
|
||||||
|
|
||||||
**Resolved 2026-07-27, after this was written.** The operator set
|
|
||||||
`REQUIRE_SIGNIN_VIEW=false`, and the mint-time path was then verified live: a
|
|
||||||
credential-less `templates_resolve` with
|
|
||||||
`RIG_TEMPLATES_HOST=https://forgejo.heavyduty.builders` fetches and extracts a
|
|
||||||
real archive. Piece 1 is proven end to end on the target instance, not merely
|
|
||||||
argued.
|
|
||||||
|
|
||||||
The requirement below stands for *any* instance hosting a registry, and the
|
|
||||||
refusal text still names it — it is a property of the mint's creds-free
|
|
||||||
contract, not of one server's configuration. What follows is the original
|
|
||||||
analysis, kept because it is why the knob exists.
|
|
||||||
|
|
||||||
### The blocker, as originally measured
|
|
||||||
|
|
||||||
`templates_resolve` documents a hard contract:
|
|
||||||
|
|
||||||
> the fetch is unauthenticated by contract (box auto-runs bootstrap at mint,
|
|
||||||
> holding nothing)
|
|
||||||
|
|
||||||
Measured: on `forgejo.heavyduty.builders`, anonymous requests for
|
|
||||||
`heavy-duty/rig` — reported by the API as `private: false` — return **404** for
|
|
||||||
the API, the web page, the git remote and the archive endpoint. Only an
|
|
||||||
authenticated request succeeds. The instance requires sign-in to view.
|
|
||||||
|
|
||||||
A mint holds no credentials, so **a Forgejo-hosted registry is unreachable at
|
|
||||||
mint time** until the instance serves public repos anonymously:
|
|
||||||
|
|
||||||
```
|
|
||||||
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a Coolify env-var change on the Forgejo service, next to the
|
|
||||||
`FORGEJO__actions__ENABLED=true` that Actions already needs. It is recorded
|
|
||||||
here and in the README as a prerequisite. Nothing in this change silently
|
|
||||||
assumes it: with the gate up, the fetch fails the way any unreachable ref
|
|
||||||
fails, listing every URL tried.
|
|
||||||
|
|
||||||
## 2. The `ci-box` tenant definition
|
|
||||||
|
|
||||||
Data, not mechanism — it belongs in the registry repo. It is staged in this PR
|
|
||||||
under `docs/templates/ci-box/` so it can be reviewed and linted here, and moves
|
|
||||||
to the registry verbatim once that repo exists on Forgejo.
|
|
||||||
|
|
||||||
```
|
|
||||||
USER="ci"
|
|
||||||
CONTEXT_PATH=".ci/CONTEXT.md"
|
|
||||||
CLI_NAME="forgejo-runner"
|
|
||||||
CLI_SRC="/usr/local/bin/forgejo-runner"
|
|
||||||
PATH_LINE="export PATH="$HOME/.local/bin:$PATH""
|
|
||||||
NEEDS_NODE="no"
|
|
||||||
```
|
|
||||||
|
|
||||||
`CLI_SRC` is absolute rather than `~/`-relative, which is the one place this
|
|
||||||
definition departs from the agent tenants. Their CLI lives in the tenant's
|
|
||||||
home; this binary is executed by a systemd unit, and a tenant-writable binary
|
|
||||||
that a root-installed unit runs is a trivial path to root inside the box. So it
|
|
||||||
lands root-owned under `/usr/local/bin`. `test/cli.sh` pins `CLI_SRC` against
|
|
||||||
the path `install.sh` actually writes — a drifted pair converges to a CLI that
|
|
||||||
exists and cannot run, which is the scar `grok-box` left.
|
|
||||||
|
|
||||||
`NEEDS_NODE="no"` because the runner is a static Go binary. Jobs get their Node
|
|
||||||
from the container image, which is the whole point of the label mapping below —
|
|
||||||
installing a second Node on the host would be a toolchain nobody reads.
|
|
||||||
|
|
||||||
`install.sh` fetches the release binary for the architecture and **verifies the
|
|
||||||
published `.sha256` before installing it**. Forgejo ships bare binaries with
|
|
||||||
`.sha256` and `.asc` beside them rather than a tarball, so a checksum is
|
|
||||||
available for free; taking it makes the install auditable in the way
|
|
||||||
`coolify install`'s version pin is.
|
|
||||||
|
|
||||||
This satisfies the tenant schema honestly rather than by paperwork:
|
|
||||||
`bootstrap-tenant.sh` asserts `<CLI> --version` answers **as the tenant user**,
|
|
||||||
and `forgejo-runner --version` does.
|
|
||||||
|
|
||||||
`creds.md` states the box holds no Forgejo credential and that registration is
|
|
||||||
a separate, operator-run act — which is true, and is what the rendered context
|
|
||||||
file needs to say.
|
|
||||||
|
|
||||||
### Why the box replaces docker-in-docker
|
|
||||||
|
|
||||||
The setup guide this design came from builds a `docker:dind` sidecar with
|
|
||||||
`privileged: true` and a plaintext `tcp://…:2375` daemon socket. Inside a
|
|
||||||
tenant that is redundant: `bootstrap-tenant.sh` already installs Docker on
|
|
||||||
every tenant and adds the tenant user to the `docker` group. The runner talks
|
|
||||||
to that daemon over its own socket.
|
|
||||||
|
|
||||||
The isolation argument that justifies dind on a shared CI server is already
|
|
||||||
paid for here by the box: it is network-isolated, disposable, and has no
|
|
||||||
inbound path. Stacking dind inside it would add a privileged container to buy
|
|
||||||
a boundary that already exists.
|
|
||||||
|
|
||||||
Note the trade this makes explicit: `docker` group membership is
|
|
||||||
root-equivalent *within the box*. `rig runner install` refuses Docker for
|
|
||||||
exactly that reason — but it converges a fleet **machine**, where the blast
|
|
||||||
radius is the machine. Here the blast radius is a guest that is thrown away.
|
|
||||||
That is the difference that makes the same trade correct in one place and wrong
|
|
||||||
in the other.
|
|
||||||
|
|
||||||
## 3. `rig forgejo-runner install|status|remove`
|
|
||||||
|
|
||||||
A new family beside `rig runner`, which is left untouched.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
box shell ci-box
|
|
||||||
sudo rig forgejo-runner install \
|
|
||||||
--instance https://forgejo.heavyduty.builders \
|
|
||||||
--name ci-runner-1
|
|
||||||
```
|
|
||||||
|
|
||||||
Default labels:
|
|
||||||
|
|
||||||
```
|
|
||||||
ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm
|
|
||||||
```
|
|
||||||
|
|
||||||
so `runs-on: ubuntu-latest` works in a workflow written for GitHub.
|
|
||||||
|
|
||||||
### Why not `rig runner --forge forgejo`
|
|
||||||
|
|
||||||
GitHub's runner registers against a repository URL, and
|
|
||||||
`assert_runner_repo` converges toward `--repo`: re-running against the same
|
|
||||||
repo is a no-op, a different repo is refused because silently restarting on the
|
|
||||||
old one would leave the requested repo with jobs queued forever.
|
|
||||||
|
|
||||||
Forgejo has no such argument. The runner registers against an **instance**, and
|
|
||||||
whether the registration is instance-wide, org, or single-repo is a property of
|
|
||||||
the *token*, decided in Forgejo's UI before rig ever sees it. There is no repo
|
|
||||||
to converge toward and nothing to compare.
|
|
||||||
|
|
||||||
Folding that into one command would make every guard bimodal to share a flag
|
|
||||||
name, while the contract underneath differs. Two families is the smaller lie.
|
|
||||||
|
|
||||||
### `register` is deprecated upstream, and is still the right call
|
|
||||||
|
|
||||||
Measured on v12.13.2: both `forgejo-runner register` and `create-runner-file`
|
|
||||||
carry `(deprecated)` in their help. The successor is
|
|
||||||
`daemon --url <instance> --uuid <uuid> --token-url <url>`, which requires the
|
|
||||||
runner to **already exist on the instance** — the operator creates it via the
|
|
||||||
API or UI and carries a UUID back.
|
|
||||||
|
|
||||||
`register` is chosen anyway:
|
|
||||||
|
|
||||||
- It still works, and this was verified rather than assumed. A planted
|
|
||||||
`.runner` made `daemon` resolve the instance from the file, connect to a live
|
|
||||||
Forgejo, and fail with `Unauthenticated: unregistered runner` — the transport
|
|
||||||
and the file format are intact; only the credential was fake.
|
|
||||||
- The successor asks the operator for a second, differently-shaped credential
|
|
||||||
dance for no gain today.
|
|
||||||
- `register` writes a file `status` can read back. Under the successor, "what
|
|
||||||
is this box registered to" has no on-disk answer that is not merely rig's own
|
|
||||||
copy of what it was told.
|
|
||||||
|
|
||||||
When upstream removes it: the unit line gains `--url`/`--uuid`, and the readers
|
|
||||||
in `forgejo-runner-config.sh` move to whatever holds the UUID.
|
|
||||||
`assert_runner_instance`'s contract survives either spelling, because it asks
|
|
||||||
about the instance — which both record.
|
|
||||||
|
|
||||||
Not verified here, and left to the drill: that `register` writes `.runner` on a
|
|
||||||
live instance. That needs a real registration token, which this design cannot
|
|
||||||
mint.
|
|
||||||
|
|
||||||
### The convergence guard that does apply
|
|
||||||
|
|
||||||
`assert_runner_instance`: re-running against the instance this box is already
|
|
||||||
registered to re-uses the binary and skips registration; a **different**
|
|
||||||
instance refuses and names both. Same trust-boundary reasoning as
|
|
||||||
`assert_runner_repo`, asked about the axis Forgejo actually has.
|
|
||||||
|
|
||||||
### Three corrections from review !110
|
|
||||||
|
|
||||||
The first draft carried three defects that review caught, all of the same
|
|
||||||
family — a stated contract that the code did not actually keep:
|
|
||||||
|
|
||||||
1. **`--version` was swallowed on the primary path.** The download block
|
|
||||||
skipped on mere presence, copying `rig runner install`'s shape without its
|
|
||||||
justification: `actions/runner` self-updates, `forgejo-runner` does not. And
|
|
||||||
a ci-box's template *preinstalls* the binary at mint, so the flag documented
|
|
||||||
as the deterministic-pin lever could never fire on a ci-box. The decision is
|
|
||||||
now `runner_download_decision` in the lib — a pure function, so the rule is
|
|
||||||
driven by tests instead of asserted by grep — and the binary is renamed into
|
|
||||||
place rather than written over, since the converge path now runs while the
|
|
||||||
daemon is live (in-place would be `ETXTBSY`).
|
|
||||||
|
|
||||||
2. **`.rig-labels` outlived the registration it described.** The write had
|
|
||||||
escaped the registration branch, where `runner-install.sh` correctly keeps
|
|
||||||
its copy. A plain re-run stamped that invocation's labels over a
|
|
||||||
registration made with different ones, and `status` then reported
|
|
||||||
confidently wrong labels while Forgejo held the originals. It is scoped
|
|
||||||
again, and an *explicit* `--labels` on a re-run now warns that Forgejo owns
|
|
||||||
labels from registration time.
|
|
||||||
|
|
||||||
3. **The checksum gate failed open.** A missing `.sha256` warned and installed
|
|
||||||
anyway — contradicting both the README and this file's own comment about
|
|
||||||
unverified root downloads. The original reasoning (do not let an upstream
|
|
||||||
layout change break installs) reasons about the wrong failure: a layout
|
|
||||||
change breaks the *binary* URL too, so "binary yes, checksum no" is the
|
|
||||||
shape of an interfered fetch, which is what the checksum is for. Both paths
|
|
||||||
refuse now, with no bypass flag.
|
|
||||||
|
|
||||||
### `.runner` holds a credential here
|
|
||||||
|
|
||||||
GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels`
|
|
||||||
**and the runner's own long-lived token** — the credential it authenticates
|
|
||||||
with on every poll.
|
|
||||||
|
|
||||||
So this family does something its GitHub sibling never had to: it installs
|
|
||||||
`.runner` as `0600` owned by the runner user, and **re-asserts that mode on
|
|
||||||
every converge**. A registration secret readable by every account on the box
|
|
||||||
would be a quiet, permanent credential leak, and convergence is the only moment
|
|
||||||
rig can notice a mode that drifted.
|
|
||||||
|
|
||||||
`status` therefore reads `address`, `name` and `labels` and never prints the
|
|
||||||
token.
|
|
||||||
|
|
||||||
### Removal
|
|
||||||
|
|
||||||
Forgejo's runner has no deregistration handshake — no removal-token endpoint,
|
|
||||||
no `config.sh remove`. `remove` stops and disables the unit and wipes the local
|
|
||||||
registration, then says plainly that the entry must be deleted in Forgejo's
|
|
||||||
admin UI. `rig runner remove`'s `--local` escape hatch is the *only* mode here,
|
|
||||||
so it is not offered as a flag that suggests a server-side alternative exists.
|
|
||||||
|
|
||||||
## Guard: `bootstrap --undo`
|
|
||||||
|
|
||||||
`bootstrap-undo.sh` refuses to leave the tailnet while a GitHub runner is
|
|
||||||
installed, so undo cannot strand a ghost runner in a repository. The same
|
|
||||||
hazard exists for a Forgejo runner on a machine, so the guard learns the
|
|
||||||
`forgejo-runner.service` unit and the `.runner` under the runner user's home.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
`test/cli.sh` is dependency-free, non-root, offline. What it can prove:
|
|
||||||
|
|
||||||
- Every new command's arg validation, `--help`, and unknown-flag refusals.
|
|
||||||
- `bin/rig` dispatch, including bare `rig forgejo-runner` showing usage.
|
|
||||||
- The forge-aware URL builder as a **pure function**, lifted and driven
|
|
||||||
directly: GitHub host yields the three-candidate list in tag-first order,
|
|
||||||
a Forgejo host yields the single `/archive/<ref>.tar.gz`.
|
|
||||||
- `templates_source_desc` names a non-default host.
|
|
||||||
- The staged `ci-box` definition passes `rig template-lint` — the same parser
|
|
||||||
a mint runs, so the definition cannot ship malformed.
|
|
||||||
- Grep-pins that the `.runner` 0600 assert and the `bootstrap --undo`
|
|
||||||
forgejo-runner guard are present, so a deleted guard cannot ship green
|
|
||||||
(the repo's existing precedent for guards that need a real machine).
|
|
||||||
|
|
||||||
What it cannot prove, and is left to the drill: a real registration against a
|
|
||||||
live Forgejo, and a job actually executing in a container.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
`install.sh` hardcodes `github.com` in `resolve_latest_tag` and
|
|
||||||
`ref_candidate_urls` (rig's own source), and `commands/bootstrap.sh` fetches
|
|
||||||
box from `raw.githubusercontent.com`. Hosting rig itself on Forgejo needs those
|
|
||||||
too. They are follow-ups, not this change — `snapshot_templates` is included
|
|
||||||
here only because it fetches *the registry*, and leaving it behind would let
|
|
||||||
the snapshot and the live fetch disagree about where the registry lives.
|
|
||||||
25
docs/templates/README.md
vendored
25
docs/templates/README.md
vendored
|
|
@ -1,25 +0,0 @@
|
||||||
# Staged template definitions
|
|
||||||
|
|
||||||
Definitions here are **not** the registry. They are role definitions destined
|
|
||||||
for `heavy-duty/rig-templates`, staged in rig's tree only while the registry
|
|
||||||
repo does not yet exist on the forge that will serve them (#109).
|
|
||||||
|
|
||||||
This is deliberately a waiting room, not a second registry:
|
|
||||||
|
|
||||||
- `bootstrap-tenant.sh` does not look here. Nothing in this directory is
|
|
||||||
reachable by a mint, and adding a lookup would recreate exactly the coupling
|
|
||||||
the registry split was written to remove — where adding a tenant meant
|
|
||||||
editing rig.
|
|
||||||
- `test/cli.sh` lints each one with `rig template-lint`, the same parser a mint
|
|
||||||
runs. A definition that cannot pass the schema never reaches the registry.
|
|
||||||
- When the registry repo exists, a definition moves there **verbatim** and is
|
|
||||||
deleted from here in the same PR.
|
|
||||||
|
|
||||||
To try one before it is anywhere, point a mint at it directly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
RIG_TEMPLATES_DIR=docs/templates rig bootstrap ci-box
|
|
||||||
```
|
|
||||||
|
|
||||||
That is `RIG_TEMPLATES_DIR`'s stated purpose — "try a template before it exists
|
|
||||||
anywhere" — and it is the supported path, not a workaround.
|
|
||||||
13
docs/templates/ci-box/creds.md
vendored
13
docs/templates/ci-box/creds.md
vendored
|
|
@ -1,13 +0,0 @@
|
||||||
- **Creds-free by default.** The box starts with no Forgejo credentials and no
|
|
||||||
git credentials. The runner binary is installed but **not registered**:
|
|
||||||
registration needs a token the operator mints in Forgejo (Site
|
|
||||||
Administration, org, or repo → Actions → Runners) and hands to
|
|
||||||
`sudo rig forgejo-runner install --instance <url>`. rig never writes that
|
|
||||||
token to disk — but it does **not expire, and registering does not spend
|
|
||||||
it**. A Forgejo registration token stays valid until somebody mints a
|
|
||||||
replacement at that same scope, and it will register as many runners as it is
|
|
||||||
shown to. Treat a leaked one as live until it has been replaced. (GitHub's
|
|
||||||
equivalent expires in about an hour; do not carry that habit across.) After
|
|
||||||
registration the runner's own credential lives in `~/forgejo-runner/.runner`,
|
|
||||||
mode 0600 — never copy it, print it, or commit it. Secrets that CI jobs need
|
|
||||||
belong in Forgejo's repo or org secrets, injected per job, not on this box.
|
|
||||||
129
docs/templates/ci-box/install.sh
vendored
129
docs/templates/ci-box/install.sh
vendored
|
|
@ -1,129 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# ci-box — the forgejo-runner binary. Run BY THE MECHANISM as root, with
|
|
||||||
# TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE exported.
|
|
||||||
#
|
|
||||||
# This lands the BINARY ONLY. Registration is deliberately not here: it needs a
|
|
||||||
# registration token from the Forgejo instance, and a tenant install is
|
|
||||||
# creds-free by contract — box auto-runs it at mint, holding nothing. The
|
|
||||||
# operator registers afterwards, out loud:
|
|
||||||
#
|
|
||||||
# Deliberately NOT described the way the GitHub sibling describes its own
|
|
||||||
# registration token, which really does expire in about an hour. That adjective
|
|
||||||
# must not cross this forge boundary — a test pins its absence from this file
|
|
||||||
# and from creds.md, so do not reintroduce it by copying from `rig runner`.
|
|
||||||
# Forgejo's ActionRunnerToken carries no expiry field at all;
|
|
||||||
# NewRunnerToken invalidates prior tokens only when a replacement is minted at
|
|
||||||
# the same scope, and Register leaves the one it was handed active. It is
|
|
||||||
# reusable until replaced, so a leak stays live. See creds.md, which is the
|
|
||||||
# copy an agent inside the box actually reads.
|
|
||||||
#
|
|
||||||
# box shell ci-box
|
|
||||||
# sudo rig forgejo-runner install --instance https://forgejo.example.com
|
|
||||||
#
|
|
||||||
# Same split as staging-box's tailnet join, for the same reason.
|
|
||||||
#
|
|
||||||
# Root-owned under /usr/local/bin rather than the tenant's home: unlike an
|
|
||||||
# agent CLI, this binary is run by a systemd unit as the tenant user, and a
|
|
||||||
# tenant-writable binary that root's unit executes is a trivial path to root
|
|
||||||
# inside the box.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
|
|
||||||
#
|
|
||||||
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
|
|
||||||
# an asset and prove the download matches it. Prints the reason on stderr and
|
|
||||||
# returns 1 on any failure; the caller supplies the refusal in its own voice.
|
|
||||||
#
|
|
||||||
# BYTE-IDENTICAL to the copy in commands/forgejo-runner-install.sh, diffed by
|
|
||||||
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
|
|
||||||
# downloaders cannot share a lib: that one sources commands/lib/, and this one
|
|
||||||
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
|
|
||||||
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
|
|
||||||
# that keeps one policy from becoming two.
|
|
||||||
#
|
|
||||||
# Review !110 is the evidence for why that matters: a fail-open branch lived in
|
|
||||||
# BOTH copies while a grep for "checksum mismatch" passed against both, because
|
|
||||||
# the string it looked for sat right beside the branch it could not see. The
|
|
||||||
# next checksum-policy change must not be able to land in one file only.
|
|
||||||
#
|
|
||||||
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
|
|
||||||
# reasoning ("do not let an upstream layout change break installs") reasons
|
|
||||||
# about the wrong failure: a layout change moves the BINARY url too, so the
|
|
||||||
# download would already have died. "Binary yes, checksum no" is not what a
|
|
||||||
# layout change looks like — it is what an interfered fetch looks like, which
|
|
||||||
# is precisely what a checksum exists to catch. Failing open would hand an
|
|
||||||
# unverified root install to anyone able to block a single URL. There is
|
|
||||||
# deliberately no bypass flag: if upstream really does move its assets, that is
|
|
||||||
# a rig PR editing the URL, not an operator improvising past a security gate.
|
|
||||||
fetch_and_verify_sha256() {
|
|
||||||
local url="$1" file="$2" sumfile="$3" label="$4" want got
|
|
||||||
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
|
|
||||||
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# The published .sha256 names the asset, not our temp path. Compare the
|
|
||||||
# digest itself rather than rewriting the file into sha256sum -c's format:
|
|
||||||
# one comparison, no parsing of a file we did not write.
|
|
||||||
want="$(tr -d '\r' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
|
|
||||||
got="$(sha256sum "$file" | awk '{print $1}')"
|
|
||||||
if [ -z "$want" ]; then
|
|
||||||
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [ "$want" != "$got" ]; then
|
|
||||||
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf 'checksum verified (%s)\n' "$got"
|
|
||||||
}
|
|
||||||
|
|
||||||
# CIBOX_BIN is a TEST-ONLY override, in the same spirit as bootstrap-undo.sh's
|
|
||||||
# RIG_FORGEJO_RUNNER_DIR: the production default is the only path the mechanism
|
|
||||||
# ever uses, but test/cli.sh must be able to drive this script on a box that
|
|
||||||
# already has a real runner installed. Without it the early-exit below fires
|
|
||||||
# against the host and the checksum checks silently test nothing (#136).
|
|
||||||
BIN="${CIBOX_BIN:-/usr/local/bin/forgejo-runner}"
|
|
||||||
|
|
||||||
if [ -x "$BIN" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64) ARCH="amd64" ;;
|
|
||||||
aarch64) ARCH="arm64" ;;
|
|
||||||
*) echo "ci-box install: unsupported arch: $(uname -m)" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# The latest release, resolved by following the releases/latest redirect — no
|
|
||||||
# API call, no token, no JSON to parse on a dependency-free guest. A pinned
|
|
||||||
# version belongs to `rig forgejo-runner install --version`, which is where an
|
|
||||||
# operator who needs a deterministic install already is; a pin baked into the
|
|
||||||
# registry would go stale in a repo nobody watches.
|
|
||||||
LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
|
|
||||||
https://code.forgejo.org/forgejo/runner/releases/latest)" \
|
|
||||||
|| { echo "ci-box install: could not resolve the latest forgejo-runner release" >&2; exit 1; }
|
|
||||||
VERSION="${LATEST_URL##*/}"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
case "$VERSION" in
|
|
||||||
""|*[!0-9.]*) echo "ci-box install: could not parse a version from ${LATEST_URL}" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
ASSET="forgejo-runner-${VERSION}-linux-${ARCH}"
|
|
||||||
URL="https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/${ASSET}"
|
|
||||||
|
|
||||||
WORKDIR="$(mktemp -d)"
|
|
||||||
cleanup() { rm -rf "$WORKDIR"; }
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
echo "ci-box install: downloading forgejo-runner ${VERSION} (${ARCH})"
|
|
||||||
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
|
||||||
|| { echo "ci-box install: could not download ${URL}" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Forgejo publishes a .sha256 beside each binary. Verifying it costs one
|
|
||||||
# request and makes the install auditable; this file executes as root inside
|
|
||||||
# every future mint, so an unverified download is the last thing it should do.
|
|
||||||
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|
|
||||||
|| { echo "ci-box install: refusing to install an unverified ${ASSET} — it lands as root inside every mint. See the checksum failure above." >&2; exit 1; }
|
|
||||||
|
|
||||||
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
|
|
||||||
echo "ci-box install: installed ${BIN}"
|
|
||||||
16
docs/templates/ci-box/template.env
vendored
16
docs/templates/ci-box/template.env
vendored
|
|
@ -1,16 +0,0 @@
|
||||||
# ci-box — the Forgejo CI tenant (#109). The box guest a fleet machine mints to
|
|
||||||
# run CI jobs: forgejo-runner polls the instance outbound, jobs run in
|
|
||||||
# containers on the dockerd bootstrap-tenant.sh already installed.
|
|
||||||
#
|
|
||||||
# The schema is rig's commands/lib/templates.sh; this is data, parsed and never
|
|
||||||
# sourced.
|
|
||||||
USER="ci"
|
|
||||||
CONTEXT_PATH=".ci/CONTEXT.md"
|
|
||||||
CLI_NAME="forgejo-runner"
|
|
||||||
CLI_SRC="/usr/local/bin/forgejo-runner"
|
|
||||||
PATH_LINE="export PATH="$HOME/.local/bin:$PATH""
|
|
||||||
# NEEDS_NODE is a question about the CLI, not about the jobs. forgejo-runner is
|
|
||||||
# a static Go binary, and every job gets its own toolchain from its container
|
|
||||||
# image — a Node on the host would be a second, invisible toolchain that no
|
|
||||||
# workflow reads.
|
|
||||||
NEEDS_NODE="no"
|
|
||||||
|
|
@ -11,7 +11,7 @@ release (#105, and #107's debt).
|
||||||
|
|
||||||
- **A throwaway Debian 13 machine** you can format, reached as root. The
|
- **A throwaway Debian 13 machine** you can format, reached as root. The
|
||||||
drill hardens its sshd, renames it, joins it to a tailnet, and installs
|
drill hardens its sshd, renames it, joins it to a tailnet, and installs
|
||||||
box/Incus, Coolify and Actions runners on it. It is not coming back.
|
box/Incus, Coolify and a GitHub runner on it. It is not coming back.
|
||||||
The machine is its own reset — there is no teardown script and no need
|
The machine is its own reset — there is no teardown script and no need
|
||||||
for one.
|
for one.
|
||||||
- **The pinned candidate refs, both of them.** `--rig-ref` and
|
- **The pinned candidate refs, both of them.** `--rig-ref` and
|
||||||
|
|
@ -34,18 +34,6 @@ release (#105, and #107's debt).
|
||||||
and does something trivial (`echo drilled`). Tokens come from an
|
and does something trivial (`echo drilled`). Tokens come from an
|
||||||
authenticated `gh`, or from `RUNNER_TOKEN` / `RUNNER_REMOVE_TOKEN`.
|
authenticated `gh`, or from `RUNNER_TOKEN` / `RUNNER_REMOVE_TOKEN`.
|
||||||
Without a fork the leg **skips, loudly, into the record**.
|
Without a fork the leg **skips, loudly, into the record**.
|
||||||
- **For leg 3's Forgejo half** (#129): `--forgejo-instance <url>` and
|
|
||||||
`--forgejo-runner-repo <owner>/<repo>`, where that repo carries the same
|
|
||||||
`workflow_dispatch` workflow — but with `runs-on: drill`, because a
|
|
||||||
Forgejo runner matches the bare label it registered with. Tokens come from
|
|
||||||
`FORGEJO_RUNNER_TOKEN` (a registration token) or `FORGEJO_API_TOKEN`, which
|
|
||||||
mints one and is also what dispatches the job. `--forgejo-ref` names the
|
|
||||||
branch to dispatch (default `main`): Forgejo's dispatch endpoint requires a
|
|
||||||
ref in the body, where GitHub's defaults to the repo's default branch.
|
|
||||||
Without an instance and a repo this half **skips, loudly and separately**.
|
|
||||||
Note there is no removal token — Forgejo has no deregistration endpoint, so
|
|
||||||
the leg removes locally and the record tells you to delete the stale runner
|
|
||||||
row by hand.
|
|
||||||
- **For leg 4** (coolify): a version pin, `--coolify-version 4.1.2`.
|
- **For leg 4** (coolify): a version pin, `--coolify-version 4.1.2`.
|
||||||
No pin, no leg — rig's own `coolify install` refuses to default a
|
No pin, no leg — rig's own `coolify install` refuses to default a
|
||||||
version and so does its drill. The skip is recorded.
|
version and so does its drill. The skip is recorded.
|
||||||
|
|
@ -92,11 +80,7 @@ passes, failures and skips separately.
|
||||||
skip, exit 0) survives into the record as a SKIP, never a pass.
|
skip, exit 0) survives into the record as a SKIP, never a pass.
|
||||||
3. **Runner lifecycle** — register against the fork, dispatch the drill
|
3. **Runner lifecycle** — register against the fork, dispatch the drill
|
||||||
workflow and watch the runner take it, deregister, and assert the
|
workflow and watch the runner take it, deregister, and assert the
|
||||||
box's registration is actually gone. Runs **once per forge**: `rig runner`
|
box's registration is actually gone.
|
||||||
against GitHub, then `rig forgejo-runner` against a Forgejo instance
|
|
||||||
(#129). Both families ship, so a release that evidences only one
|
|
||||||
evidences half of what it ships; each half skips separately, so a record
|
|
||||||
can honestly show one forge drilled and the other not.
|
|
||||||
4. **Coolify** — installed at the pin, `AUTOUPDATE=false` landed in the
|
4. **Coolify** — installed at the pin, `AUTOUPDATE=false` landed in the
|
||||||
effective `.env`, container running.
|
effective `.env`, container running.
|
||||||
|
|
||||||
|
|
|
||||||
295
drill/drill.sh
295
drill/drill.sh
|
|
@ -4,16 +4,13 @@
|
||||||
# ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY Debian machine you
|
# ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY Debian machine you
|
||||||
# can format. It wipes any installed rig and reinstalls from the pinned
|
# can format. It wipes any installed rig and reinstalls from the pinned
|
||||||
# ref, hardens sshd, sets the hostname, joins the tailnet, installs box
|
# ref, hardens sshd, sets the hostname, joins the tailnet, installs box
|
||||||
# and its Incus stack, installs Coolify and Actions runners (GitHub, and
|
# and its Incus stack, installs Coolify and a GitHub Actions runner.
|
||||||
# Forgejo when --forgejo-instance is given).
|
|
||||||
# Never run it on a machine you care about.
|
# Never run it on a machine you care about.
|
||||||
#
|
#
|
||||||
# TS_AUTHKEY=tskey-... bash drill/drill.sh \
|
# TS_AUTHKEY=tskey-... bash drill/drill.sh \
|
||||||
# --rig-ref release/0.4.0 --box-ref 0.9.0 \
|
# --rig-ref release/0.4.0 --box-ref 0.9.0 \
|
||||||
# --users ./drill-users --run-id drill-2026-07-24-a \
|
# --users ./drill-users --run-id drill-2026-07-24-a \
|
||||||
# --coolify-version 4.1.2 --runner-repo you/rig \
|
# --coolify-version 4.1.2 --runner-repo you/rig --yes
|
||||||
# --forgejo-instance https://forgejo.example.com \
|
|
||||||
# --forgejo-runner-repo you/drill-probe --yes
|
|
||||||
# (--box-ref is a tag: since #103 the box that ships is the BOX_RELEASE tag.)
|
# (--box-ref is a tag: since #103 the box that ships is the BOX_RELEASE tag.)
|
||||||
# rig's drill asserts CONVERGENCE — a machine reaches its role, idempotently.
|
# rig's drill asserts CONVERGENCE — a machine reaches its role, idempotently.
|
||||||
# The legs (drills/README.md, issue #105):
|
# The legs (drills/README.md, issue #105):
|
||||||
|
|
@ -25,10 +22,6 @@
|
||||||
# the isolation boundary is box's drill's assertion, not this one's).
|
# the isolation boundary is box's drill's assertion, not this one's).
|
||||||
# 2. db — the real dump/restore round-trip, test/db-integration.sh.
|
# 2. db — the real dump/restore round-trip, test/db-integration.sh.
|
||||||
# 3. runner lifecycle — register, take a job, deregister, against a fork.
|
# 3. runner lifecycle — register, take a job, deregister, against a fork.
|
||||||
# Runs once per forge: `rig runner` against GitHub (--runner-repo), and
|
|
||||||
# `rig forgejo-runner` against a Forgejo instance (--forgejo-instance +
|
|
||||||
# --forgejo-runner-repo). Both forges ship, so both need evidence; each
|
|
||||||
# skips loudly and separately when its inputs are absent (#129).
|
|
||||||
# 4. coolify install — at a pinned version, AUTOUPDATE=false.
|
# 4. coolify install — at a pinned version, AUTOUPDATE=false.
|
||||||
#
|
#
|
||||||
# Execution order is 1, 4, 2, 3 — coolify's installer is what puts Docker on
|
# Execution order is 1, 4, 2, 3 — coolify's installer is what puts Docker on
|
||||||
|
|
@ -66,7 +59,6 @@ BOXREF="${BOX_REF:-}"
|
||||||
TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
||||||
TPLREF="${RIG_TEMPLATES_REF:-}"
|
TPLREF="${RIG_TEMPLATES_REF:-}"
|
||||||
TPL_SHA=""
|
TPL_SHA=""
|
||||||
TPL_SOURCE="fetched"
|
|
||||||
ROLE=staging-server
|
ROLE=staging-server
|
||||||
USERS_FILE="${DRILL_USERS_FILE:-}"
|
USERS_FILE="${DRILL_USERS_FILE:-}"
|
||||||
RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}"
|
RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}"
|
||||||
|
|
@ -74,11 +66,6 @@ RECORD="${DRILL_RECORD:-}"
|
||||||
COOLIFY_VERSION="${DRILL_COOLIFY_VERSION:-}"
|
COOLIFY_VERSION="${DRILL_COOLIFY_VERSION:-}"
|
||||||
RUNNER_REPO="${DRILL_RUNNER_REPO:-}"
|
RUNNER_REPO="${DRILL_RUNNER_REPO:-}"
|
||||||
RUNNER_WORKFLOW="${DRILL_RUNNER_WORKFLOW:-drill.yml}"
|
RUNNER_WORKFLOW="${DRILL_RUNNER_WORKFLOW:-drill.yml}"
|
||||||
FJ_INSTANCE="${DRILL_FORGEJO_INSTANCE:-}"
|
|
||||||
FJ_RUNNER_REPO="${DRILL_FORGEJO_RUNNER_REPO:-}"
|
|
||||||
# The branch the dispatch names. Forgejo's dispatch endpoint requires a ref in
|
|
||||||
# the body — unlike GitHub's, which defaults to the repo's default branch.
|
|
||||||
FJ_REF="${DRILL_FORGEJO_REF:-main}"
|
|
||||||
YES=0
|
YES=0
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
|
|
@ -95,10 +82,7 @@ while [ $# -gt 0 ]; do
|
||||||
--coolify-version) COOLIFY_VERSION="$2"; shift 2 ;;
|
--coolify-version) COOLIFY_VERSION="$2"; shift 2 ;;
|
||||||
--runner-repo) RUNNER_REPO="$2"; shift 2 ;;
|
--runner-repo) RUNNER_REPO="$2"; shift 2 ;;
|
||||||
--runner-workflow) RUNNER_WORKFLOW="$2"; shift 2 ;;
|
--runner-workflow) RUNNER_WORKFLOW="$2"; shift 2 ;;
|
||||||
--forgejo-instance) FJ_INSTANCE="$2"; shift 2 ;;
|
-h|--help) sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
--forgejo-runner-repo) FJ_RUNNER_REPO="$2"; shift 2 ;;
|
|
||||||
--forgejo-ref) FJ_REF="$2"; shift 2 ;;
|
|
||||||
-h|--help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
||||||
*) echo "drill: unknown option: $1 (see --help)" >&2; exit 2 ;;
|
*) echo "drill: unknown option: $1 (see --help)" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
@ -123,133 +107,6 @@ phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
|
||||||
LEG_NAMES=(); LEG_RESULTS=()
|
LEG_NAMES=(); LEG_RESULTS=()
|
||||||
leg() { LEG_NAMES+=("$1"); LEG_RESULTS+=("$2"); }
|
leg() { LEG_NAMES+=("$1"); LEG_RESULTS+=("$2"); }
|
||||||
|
|
||||||
# forgejo_run_verdict <pre_id> <tasks-json> — the verdict for OUR dispatch:
|
|
||||||
# success | failed | pending. Reads GET /repos/{o}/{r}/actions/tasks, whose
|
|
||||||
# shape is NOT GitHub's and was measured against forgejo.heavyduty.builders
|
|
||||||
# (8.0.3+gitea-1.22.0) on 2026-07-30 rather than read from the docs:
|
|
||||||
#
|
|
||||||
# * There is no `conclusion` field. `status` carries the terminal outcome
|
|
||||||
# directly ("success"), where GitHub splits status:completed +
|
|
||||||
# conclusion:success. Reading `conclusion` here gets an empty string on
|
|
||||||
# every run, which would grade a green job as failed.
|
|
||||||
# * `id` is a GLOBAL task id; the run's own URL ends in `run_number`. The
|
|
||||||
# pre-dispatch guard therefore compares `id`, exactly as the GitHub leg
|
|
||||||
# compares databaseId — an old run must never be read as this one.
|
|
||||||
# * The payload lists ASSIGNED tasks only. A run sitting queued is simply
|
|
||||||
# absent (measured: 200s of total_count 0 while the web UI showed the run
|
|
||||||
# as "job is not started"). So "no new id" is the ONLY signal that the
|
|
||||||
# runner never took the job — which is the verdict this leg exists for.
|
|
||||||
# * A task appears when ASSIGNED, so it can be seen mid-flight. A
|
|
||||||
# non-terminal status is pending, not failed; grading a running job as a
|
|
||||||
# failure would make the leg flaky inside its own watch window.
|
|
||||||
#
|
|
||||||
# EVERY entry is inspected, and the NEWEST id above pre_id decides — never
|
|
||||||
# entry[0]. actions/tasks accumulates, so the moment a repo is drilled twice
|
|
||||||
# our run shares the payload with older ones, and nothing documents the sort
|
|
||||||
# order. Reading the first entry made a green job report as a timeout, a false
|
|
||||||
# FAILURE on the gate this leg exists to provide (grok/kimi on !130).
|
|
||||||
#
|
|
||||||
# grep-and-sed, not jq: a throwaway drill machine has neither jq nor an
|
|
||||||
# authenticated forge CLI, the same constraint json_field() carries in
|
|
||||||
# commands/lib/runner-config.sh. `id` is a bare number, which json_field's
|
|
||||||
# quoted-value shape cannot read, so this reads both forms itself. Newlines are
|
|
||||||
# stripped first so a pretty-printed payload parses identically to a compact
|
|
||||||
# one — the instance documents neither.
|
|
||||||
forgejo_run_verdict() {
|
|
||||||
local pre="$1" file="$2" obj id best_id="" best_st=""
|
|
||||||
[ -r "$file" ] || { echo pending; return 0; }
|
|
||||||
while IFS= read -r obj; do
|
|
||||||
[ -n "$obj" ] || continue
|
|
||||||
id="$(printf '%s' "$obj" | grep -o '"id"[[:space:]]*:[[:space:]]*[0-9][0-9]*' \
|
|
||||||
| head -n1 | sed 's/.*:[[:space:]]*//')"
|
|
||||||
[ -n "$id" ] || continue
|
|
||||||
# Strictly newer than the pre-dispatch id. Equal is the run that was
|
|
||||||
# already there; lower is older still.
|
|
||||||
if [ -n "$pre" ]; then
|
|
||||||
[ "$id" -gt "$pre" ] 2>/dev/null || continue
|
|
||||||
fi
|
|
||||||
if [ -z "$best_id" ] || [ "$id" -gt "$best_id" ] 2>/dev/null; then
|
|
||||||
best_id="$id"
|
|
||||||
best_st="$(printf '%s' "$obj" | grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
||||||
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//')"
|
|
||||||
fi
|
|
||||||
done <<EOF
|
|
||||||
$(tr -d '\n' < "$file" 2>/dev/null | grep -o '{[^{}]*}')
|
|
||||||
EOF
|
|
||||||
[ -n "$best_id" ] || { echo pending; return 0; }
|
|
||||||
case "$best_st" in
|
|
||||||
success) echo success ;;
|
|
||||||
failure | cancelled | skipped | timedout) echo failed ;;
|
|
||||||
*) echo pending ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_leg_row <install_ok> <status_ok> <took> <remove_ok> <absent_ok>
|
|
||||||
# The record row for the Forgejo runner leg. PASS requires the WHOLE lifecycle,
|
|
||||||
# not just the take-a-job outcome.
|
|
||||||
#
|
|
||||||
# Keying the row on <took> alone let it read "PASS — registered, took a job,
|
|
||||||
# removed" when install had failed, because the dispatched job only needs
|
|
||||||
# SOMETHING answering runs-on: drill — and this leg removes locally, telling the
|
|
||||||
# operator to delete the stale runner by hand, so a leftover drill-labeled
|
|
||||||
# runner from the previous drill is the designed-for aftermath rather than a
|
|
||||||
# contrived case (codex/grok/kimi on !130). drills/<v>.md is the release's
|
|
||||||
# durable evidence; a row claiming a lifecycle that did not happen is exactly
|
|
||||||
# what the gate exists to refuse.
|
|
||||||
#
|
|
||||||
# The drill's exit code was never wrong here — every one of those failures also
|
|
||||||
# called `no`. What was wrong is the row, and the row is what outlives the run.
|
|
||||||
forgejo_leg_row() {
|
|
||||||
local install_ok="$1" status_ok="$2" took="$3" remove_ok="$4" absent_ok="$5"
|
|
||||||
if [ "$install_ok" != 1 ] || [ "$status_ok" != 1 ] \
|
|
||||||
|| [ "$remove_ok" != 1 ] || [ "$absent_ok" != 1 ]; then
|
|
||||||
echo "FAIL — see Failed below"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
case "$took" in
|
|
||||||
success) echo "PASS — registered, took a job, removed (stale row needs deleting by hand)" ;;
|
|
||||||
none) echo "PARTIAL — registered and removed; took a job: not attempted (no FORGEJO_API_TOKEN)" ;;
|
|
||||||
*) echo "FAIL — see Failed below" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_max_task_id <tasks-json> — the highest numeric task id in the payload,
|
|
||||||
# empty when there is none. This is the PRE-DISPATCH baseline, and it must fold
|
|
||||||
# max exactly as forgejo_run_verdict does: taking the first id instead names an
|
|
||||||
# OLD run as the baseline whenever the payload is not newest-first (the order is
|
|
||||||
# undocumented). A later poll that finds the same body then reads the PREVIOUS
|
|
||||||
# drill's run as this dispatch's result — a false PASS on the take-a-job
|
|
||||||
# assertion, which is worse than the false FAIL the same mistake caused inside
|
|
||||||
# the verdict (grok/kimi, !130). The GitHub leg is safe from this only because
|
|
||||||
# `gh run list --limit 1` contracts newest-first; this API contracts nothing.
|
|
||||||
forgejo_max_task_id() {
|
|
||||||
local file="$1" id best=""
|
|
||||||
[ -r "$file" ] || return 0
|
|
||||||
while IFS= read -r id; do
|
|
||||||
[ -n "$id" ] || continue
|
|
||||||
if [ -z "$best" ] || [ "$id" -gt "$best" ] 2>/dev/null; then best="$id"; fi
|
|
||||||
done <<EOF
|
|
||||||
$(tr -d '\n' < "$file" 2>/dev/null \
|
|
||||||
| grep -o '"id"[[:space:]]*:[[:space:]]*[0-9][0-9]*' | sed 's/.*:[[:space:]]*//')
|
|
||||||
EOF
|
|
||||||
printf '%s\n' "$best"
|
|
||||||
}
|
|
||||||
|
|
||||||
# forgejo_token_verdict <resolved_token> <api_token> — ok | mint-failed | no-source.
|
|
||||||
# #129's acceptance: "Token source present but the instance is unreachable ->
|
|
||||||
# the leg FAILS; it must not skip and must not pass". A mint that yields
|
|
||||||
# nothing — unreachable instance, under-scoped token, wrong repo — is a
|
|
||||||
# CONFIGURED leg failing, and reporting it as "no token source" both writes
|
|
||||||
# SKIPPED where the record owes a FAIL and sends the operator to check an env
|
|
||||||
# var they already set. Absent inputs are the only honest skip.
|
|
||||||
forgejo_token_verdict() {
|
|
||||||
if [ -n "$1" ]; then echo ok
|
|
||||||
elif [ -n "$2" ]; then echo mint-failed
|
|
||||||
else echo no-source
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# run_logged <log> <cmd...> — run a long command with its narration in a file
|
# run_logged <log> <cmd...> — run a long command with its narration in a file
|
||||||
# and a dot every 5s on the terminal: a silent multi-minute apt/install run is
|
# and a dot every 5s on the terminal: a silent multi-minute apt/install run is
|
||||||
# indistinguishable from a wedge, and that ambiguity has cost box whole
|
# indistinguishable from a wedge, and that ambiguity has cost box whole
|
||||||
|
|
@ -417,9 +274,8 @@ emit_record() {
|
||||||
printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt"
|
printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt"
|
||||||
printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \
|
printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \
|
||||||
"${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF"
|
"${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF"
|
||||||
printf 'Template registry: %s@%s (ref %s, %s) — the rig-templates source the converge read (#110/#153).\n' \
|
printf 'Template registry: %s@%s (ref %s) — the rig-templates the converge read (#110).\n' \
|
||||||
"${TPLREPO:-heavy-duty/rig-templates}" "${TPL_SHA:-unresolved}" \
|
"${TPLREPO:-heavy-duty/rig-templates}" "${TPL_SHA:-unresolved}" "${TPLREF:-unresolved}"
|
||||||
"${TPLREF:-unresolved}" "${TPL_SOURCE:-fetched}"
|
|
||||||
printf 'Instrument: drill/drill.sh, legs in execution order.\n\n'
|
printf 'Instrument: drill/drill.sh, legs in execution order.\n\n'
|
||||||
printf '| Leg | Result |\n'
|
printf '| Leg | Result |\n'
|
||||||
printf '| --- | --- |\n'
|
printf '| --- | --- |\n'
|
||||||
|
|
@ -494,7 +350,7 @@ This will, ON THIS HOST ($(hostname)):
|
||||||
· run 'rig bootstrap $ROLE --users $USERS_FILE' — sshd hardening, hostname
|
· run 'rig bootstrap $ROLE --users $USERS_FILE' — sshd hardening, hostname
|
||||||
change, tailnet join, box ($BOXREPO@$BOXREF) + its Incus stack — TWICE
|
change, tailnet join, box ($BOXREPO@$BOXREF) + its Incus stack — TWICE
|
||||||
(the second run is the idempotence assertion)
|
(the second run is the idempotence assertion)
|
||||||
· install Coolify${COOLIFY_VERSION:+ $COOLIFY_VERSION}, a GitHub runner${RUNNER_REPO:+ against $RUNNER_REPO} and a Forgejo runner${FJ_INSTANCE:+ against $FJ_INSTANCE}${FJ_RUNNER_REPO:+ ($FJ_RUNNER_REPO)}
|
· install Coolify${COOLIFY_VERSION:+ $COOLIFY_VERSION} and a GitHub runner${RUNNER_REPO:+ against $RUNNER_REPO}
|
||||||
Only do this on a THROWAWAY machine you can format.
|
Only do this on a THROWAWAY machine you can format.
|
||||||
EOF
|
EOF
|
||||||
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
|
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
|
||||||
|
|
@ -540,17 +396,13 @@ ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)"
|
||||||
# through ref_sha like the two candidates above.
|
# through ref_sha like the two candidates above.
|
||||||
if [ -z "$TPLREF" ]; then
|
if [ -z "$TPLREF" ]; then
|
||||||
TPLREF="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$RIG_TREE/commands/lib/templates.sh" 2>/dev/null | head -n1)"
|
TPLREF="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$RIG_TREE/commands/lib/templates.sh" 2>/dev/null | head -n1)"
|
||||||
if [ -n "$TPLREF" ] &&
|
|
||||||
[ -n "$(find "$RIG_TREE/templates@$TPLREF" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)" ]; then
|
|
||||||
TPL_SOURCE="snapshot"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then
|
if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||||
TPL_SHA="${TPLREF:0:7}"
|
TPL_SHA="${TPLREF:0:7}"
|
||||||
elif [ -n "$TPLREF" ]; then
|
elif [ -n "$TPLREF" ]; then
|
||||||
TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")"
|
TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")"
|
||||||
fi
|
fi
|
||||||
inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved}, $TPL_SOURCE)"
|
inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved})"
|
||||||
[ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md"
|
[ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -832,139 +684,6 @@ else
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
phase "Leg 3 — forgejo runner lifecycle against an instance"
|
|
||||||
# =============================================================================
|
|
||||||
# The same leg as above, for the other forge. Both forges ship a runner family
|
|
||||||
# (#109 added `rig forgejo-runner` beside `rig runner`), so a release that
|
|
||||||
# evidences only GitHub evidences half of what it ships (#129).
|
|
||||||
#
|
|
||||||
# Three things differ from the GitHub leg, all measured against
|
|
||||||
# forgejo.heavyduty.builders (8.0.3+gitea-1.22.0) on 2026-07-30, not read:
|
|
||||||
#
|
|
||||||
# * Scope is the TOKEN's, never a flag — `rig forgejo-runner install` refuses
|
|
||||||
# --repo on purpose (commands/forgejo-runner-install.sh:160). The repo here
|
|
||||||
# is only where the registration token is minted from, and where the
|
|
||||||
# dispatched workflow lives.
|
|
||||||
# * The mint path is /repos/<o>/<r>/actions/runners/registration-token. The
|
|
||||||
# instance's own swagger documents /repos/<o>/<r>/runners/registration-token
|
|
||||||
# — WITHOUT /actions/ — and that path 404s. Do not "fix" this to match the
|
|
||||||
# published API reference.
|
|
||||||
# * There is no deregistration endpoint, so there is no removal token and no
|
|
||||||
# remote deregistration: `rig forgejo-runner remove` is local-only by
|
|
||||||
# design (commands/forgejo-runner-remove.sh:7-11) and the runner row
|
|
||||||
# survives in the UI until a human deletes it. The record says so rather
|
|
||||||
# than implying a clean remote teardown the way the GitHub leg can.
|
|
||||||
#
|
|
||||||
# Tokens: FORGEJO_RUNNER_TOKEN (a registration token) is used directly; else
|
|
||||||
# FORGEJO_API_TOKEN mints one from FJ_RUNNER_REPO. Without an instance, a repo,
|
|
||||||
# or a token source the leg SKIPS loudly and the record says it did not run.
|
|
||||||
if [ -z "$FJ_INSTANCE" ] || [ -z "$FJ_RUNNER_REPO" ]; then
|
|
||||||
skip "forgejo runner lifecycle: no --forgejo-instance/--forgejo-runner-repo given — the leg did not run"
|
|
||||||
leg "forgejo runner lifecycle" "SKIPPED — no instance/repo provided"
|
|
||||||
else
|
|
||||||
fj_reg="${FORGEJO_RUNNER_TOKEN:-}"
|
|
||||||
if [ -z "$fj_reg" ] && [ -n "${FORGEJO_API_TOKEN:-}" ]; then
|
|
||||||
fj_reg="$(curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
|
|
||||||
"${FJ_INSTANCE%/}/api/v1/repos/${FJ_RUNNER_REPO}/actions/runners/registration-token" 2>/dev/null \
|
|
||||||
| grep -o '"token"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 \
|
|
||||||
| sed 's/.*:[[:space:]]*"//; s/"$//')"
|
|
||||||
fi
|
|
||||||
fj_tok_verdict="$(forgejo_token_verdict "$fj_reg" "${FORGEJO_API_TOKEN:-}")"
|
|
||||||
if [ "$fj_tok_verdict" = mint-failed ]; then
|
|
||||||
# Configured, and it did not work. Never a skip: see forgejo_token_verdict.
|
|
||||||
no "registration-token mint FAILED against ${FJ_INSTANCE} — is it reachable, and does FORGEJO_API_TOKEN own ${FJ_RUNNER_REPO}? (the token is never printed)"
|
|
||||||
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" "FAIL — registration-token mint failed"
|
|
||||||
elif [ "$fj_tok_verdict" = no-source ]; then
|
|
||||||
skip "forgejo runner lifecycle: no FORGEJO_RUNNER_TOKEN and no FORGEJO_API_TOKEN to mint one — the leg did not run"
|
|
||||||
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" "SKIPPED — no registration token source"
|
|
||||||
else
|
|
||||||
FJ_NAME="drill-$(hostname)-$$"
|
|
||||||
fj_install_ok=0 fj_status_ok=0 fj_remove_ok=0 fj_absent_ok=0
|
|
||||||
# The label MUST carry a docker:// image: forgejo-runner runs jobs in
|
|
||||||
# containers, and a bare label leaves runs-on matched but unrunnable.
|
|
||||||
if FORGEJO_RUNNER_TOKEN="$fj_reg" run_logged /tmp/drill-forgejo-runner-install.log \
|
|
||||||
rig forgejo-runner install --instance "$FJ_INSTANCE" --name "$FJ_NAME" \
|
|
||||||
--labels 'drill:docker://node:22-bookworm'; then
|
|
||||||
ok "rig forgejo-runner install --instance $FJ_INSTANCE exited 0 (registered as $FJ_NAME)"
|
|
||||||
fj_install_ok=1
|
|
||||||
else
|
|
||||||
no "forgejo-runner install FAILED — tail: $(tail -3 /tmp/drill-forgejo-runner-install.log | tr '\n' ' ')"
|
|
||||||
fi
|
|
||||||
if rig forgejo-runner status 2>/dev/null | grep -qF "${FJ_INSTANCE%/}"; then
|
|
||||||
ok "forgejo-runner status names the instance: $FJ_INSTANCE"; fj_status_ok=1
|
|
||||||
else
|
|
||||||
no "forgejo-runner status does not name ${FJ_INSTANCE}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
fj_took=none
|
|
||||||
# Do NOT dispatch once install or status has failed. The job would be taken
|
|
||||||
# by whatever else answers runs-on: drill — a stale runner this leg's own
|
|
||||||
# hand-delete caveat leaves behind — and its success would be evidence about
|
|
||||||
# someone else's runner (codex/grok/kimi, !130).
|
|
||||||
if [ "$fj_install_ok" != 1 ] || [ "$fj_status_ok" != 1 ]; then
|
|
||||||
skip "took a job: not attempted — install or status failed, and a foreign runner answering 'drill' could only manufacture a false pass"
|
|
||||||
elif [ -n "${FORGEJO_API_TOKEN:-}" ]; then
|
|
||||||
fj_api="${FJ_INSTANCE%/}/api/v1/repos/${FJ_RUNNER_REPO}"
|
|
||||||
# Read the newest ASSIGNED task id BEFORE dispatching, same guard as the
|
|
||||||
# GitHub leg: an already-completed run must never be read as ours.
|
|
||||||
fj_pre_body="$(curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
|
|
||||||
"$fj_api/actions/tasks" 2>/dev/null || echo '{}')"
|
|
||||||
printf '%s' "$fj_pre_body" > /tmp/drill-forgejo-pre.json
|
|
||||||
fj_pre="$(forgejo_max_task_id /tmp/drill-forgejo-pre.json)"
|
|
||||||
if curl -fsSL -o /dev/null -X POST -H "Authorization: token ${FORGEJO_API_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" -d "{\"ref\":\"${FJ_REF}\"}" \
|
|
||||||
"$fj_api/actions/workflows/${RUNNER_WORKFLOW}/dispatches" 2>/dev/null; then
|
|
||||||
inf "dispatched $RUNNER_WORKFLOW on $FJ_RUNNER_REPO — waiting for the runner to take it (≤5 min)…"
|
|
||||||
fj_took=timeout
|
|
||||||
for _i in $(seq 1 30); do
|
|
||||||
sleep 10
|
|
||||||
curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
|
|
||||||
"$fj_api/actions/tasks" -o /tmp/drill-forgejo-tasks.json 2>/dev/null || continue
|
|
||||||
case "$(forgejo_run_verdict "${fj_pre:-}" /tmp/drill-forgejo-tasks.json)" in
|
|
||||||
success) fj_took=success; break ;;
|
|
||||||
failed) fj_took=failed; break ;;
|
|
||||||
*) : ;; # pending — queued, or assigned and still running
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
else
|
|
||||||
fj_took=nodispatch
|
|
||||||
fi
|
|
||||||
case "$fj_took" in
|
|
||||||
success) ok "the forgejo runner took a job and it succeeded ($RUNNER_WORKFLOW)" ;;
|
|
||||||
failed) no "the dispatched job completed UNSUCCESSFULLY — the runner ran it, the workflow failed; read the run on $FJ_RUNNER_REPO" ;;
|
|
||||||
# A queued task is INVISIBLE in this API until a runner claims it, so a
|
|
||||||
# timeout means "nothing ever took it". Two causes, and the second one
|
|
||||||
# is not rig's: the runs-on label may not match, or the daemon's poller
|
|
||||||
# can go quiet — a restarted daemon claims a minutes-old backlog in
|
|
||||||
# about a second. Check 'systemctl restart forgejo-runner' before
|
|
||||||
# reading this as a rig defect.
|
|
||||||
timeout) no "the dispatched job was never taken within 5 min — check the workflow's runs-on is 'drill', then restart forgejo-runner and re-read (a quiet poller looks exactly like this)" ;;
|
|
||||||
nodispatch) no "could not dispatch $RUNNER_WORKFLOW on $FJ_RUNNER_REPO — does it carry that workflow, with workflow_dispatch, on its default branch?" ;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
skip "took a job: not attempted — no FORGEJO_API_TOKEN to dispatch $RUNNER_WORKFLOW with"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# No removal token exists on this forge — remove is local by design.
|
|
||||||
if rig forgejo-runner remove >/dev/null 2>&1; then
|
|
||||||
note "forgejo-runner removed locally — Forgejo has no deregistration endpoint, so DELETE the stale '$FJ_NAME' row under $FJ_RUNNER_REPO > Settings > Actions > Runners by hand"
|
|
||||||
fj_remove_ok=1
|
|
||||||
else
|
|
||||||
no "forgejo-runner remove FAILED"
|
|
||||||
fi
|
|
||||||
if rig forgejo-runner status >/dev/null 2>&1; then
|
|
||||||
no "forgejo-runner status still answers after remove — the removal did not take"
|
|
||||||
else
|
|
||||||
ok "forgejo-runner status confirms: nothing registered"; fj_absent_ok=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" \
|
|
||||||
"$(forgejo_leg_row "$fj_install_ok" "$fj_status_ok" "$fj_took" \
|
|
||||||
"$fj_remove_ok" "$fj_absent_ok")"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
phase "Summary"
|
phase "Summary"
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,6 @@ Candidate refs: box@1a2b3c4 (BOX_REF=release/0.4.0), rig@5d6e7f8, cast@9a0b1c2.
|
||||||
| --host yes: pinned box installed, host stack up | PASS — box doctor clean |
|
| --host yes: pinned box installed, host stack up | PASS — box doctor clean |
|
||||||
| `test/db-integration.sh` | PASS — 14 passed, 0 failed |
|
| `test/db-integration.sh` | PASS — 14 passed, 0 failed |
|
||||||
| runner lifecycle against a fork | PASS — registered, took a job, deregistered clean |
|
| runner lifecycle against a fork | PASS — registered, took a job, deregistered clean |
|
||||||
| forgejo runner lifecycle (you/drill-probe) | PASS — registered, took a job, removed (stale row needs deleting by hand) |
|
|
||||||
| coolify install (4.1.2) | PASS (6 min) |
|
| coolify install (4.1.2) | PASS (6 min) |
|
||||||
|
|
||||||
Failed: `rig users apply` left one revoked key in `authorized_keys`
|
Failed: `rig users apply` left one revoked key in `authorized_keys`
|
||||||
|
|
|
||||||
156
install.sh
156
install.sh
|
|
@ -37,11 +37,6 @@ set -euo pipefail
|
||||||
|
|
||||||
REPO="${RIG_REPO:-heavy-duty/rig}"
|
REPO="${RIG_REPO:-heavy-duty/rig}"
|
||||||
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
||||||
# The forge this REPO lives on is RIG_HOST (#111), default https://github.com.
|
|
||||||
# Parallel to RIG_TEMPLATES_HOST — not the same variable, because the registry
|
|
||||||
# and rig itself may live on different forges. Default stays GitHub so every
|
|
||||||
# existing curl|bash one-liner is byte-unchanged; set
|
|
||||||
# RIG_HOST=https://forgejo.heavyduty.builders to install from this instance.
|
|
||||||
|
|
||||||
# cloud-init's runcmd runs with NO $HOME in the environment, and under set -u
|
# cloud-init's runcmd runs with NO $HOME in the environment, and under set -u
|
||||||
# the expansions just below turned that into a death instead of an install —
|
# the expansions just below turned that into a death instead of an install —
|
||||||
|
|
@ -106,18 +101,11 @@ warn_bootstrapped() { # $1 = what is about to happen
|
||||||
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
|
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
|
||||||
# rate-limit pain. A repo with no releases redirects to /releases — not to
|
# rate-limit pain. A repo with no releases redirects to /releases — not to
|
||||||
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
|
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
|
||||||
# and the CALLER owns the loud story. Host comes from RIG_HOST (default
|
# and the CALLER owns the loud story. test/release.sh extracts this function
|
||||||
# GitHub); both GitHub and Forgejo serve the same /releases/latest →
|
# (awk, the valid_version idiom) and drives it against a stubbed curl.
|
||||||
# /releases/tag/<tag> redirect grammar, measured 2026-07-29 (#111).
|
|
||||||
# test/release.sh extracts this function (awk, the valid_version idiom) and
|
|
||||||
# drives it against a stubbed curl.
|
|
||||||
resolve_latest_tag() {
|
resolve_latest_tag() {
|
||||||
# Default is inlined (not $RIG_HOST_DEFAULT) so test/release.sh's awk
|
local loc
|
||||||
# extract of this function stays self-contained — same discipline as
|
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$1/releases/latest")" || return 1
|
||||||
# valid_version.
|
|
||||||
local host="${RIG_HOST:-https://github.com}" loc
|
|
||||||
host="${host%/}"
|
|
||||||
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "$host/$1/releases/latest")" || return 1
|
|
||||||
case "$loc" in
|
case "$loc" in
|
||||||
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
|
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
|
|
@ -125,69 +113,12 @@ resolve_latest_tag() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ref_candidate_urls <owner/repo> <ref> — the download candidates for an
|
# ref_candidate_urls <owner/repo> <ref> — the download candidates for an
|
||||||
# explicit RIG_REF, in order. Host comes from RIG_HOST. Both GitHub and
|
# explicit RIG_REF, in order: refs/tags first, so a tag always outranks a
|
||||||
# Forgejo (measured 2026-07-29 on forgejo.heavyduty.builders 8.0.3) serve
|
# branch that happens to share its name (the pin must win), refs/heads as
|
||||||
# the same two paths and the same disambiguation: refs/tags first so a pin
|
# the fallback that keeps RIG_REF=main the dev channel.
|
||||||
# always outranks a same-named branch, then refs/heads for RIG_REF=main.
|
|
||||||
# Host is the only forge-specific input — no second grammar (#111).
|
|
||||||
#
|
|
||||||
# The RELEASE channel (RIG_REF unset) must NOT use this list: a missing tag
|
|
||||||
# archive must fail loudly, never fall through to a same-named branch and
|
|
||||||
# still report the resolved tag in INSTALLED_FROM. Use release_tag_url.
|
|
||||||
ref_candidate_urls() {
|
ref_candidate_urls() {
|
||||||
local host="${RIG_HOST:-https://github.com}"
|
printf 'https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$1" "$2"
|
||||||
host="${host%/}"
|
printf 'https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$1" "$2"
|
||||||
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$1" "$2"
|
|
||||||
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$1" "$2"
|
|
||||||
}
|
|
||||||
|
|
||||||
# release_tag_url <owner/repo> <tag> — the RELEASE channel is tag-only on
|
|
||||||
# every forge (#111 / #32). One URL, refs/tags only: if that archive is
|
|
||||||
# gone the install dies, it never quietly takes refs/heads/<tag>.
|
|
||||||
release_tag_url() {
|
|
||||||
local host="${RIG_HOST:-https://github.com}"
|
|
||||||
host="${host%/}"
|
|
||||||
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$1" "$2"
|
|
||||||
}
|
|
||||||
|
|
||||||
# install_script_url — the curl|bash entrypoint URL for this REPO on RIG_HOST.
|
|
||||||
# GitHub serves raw files at raw.githubusercontent.com; Forgejo at
|
|
||||||
# /raw/branch/<ref>/<path>. The refusal hint and bin/rig usage() both print
|
|
||||||
# this, so a Forgejo install never tells the operator to hit a 404 (#111).
|
|
||||||
# REPO is the installer's global (RIG_REPO); tests that extract this function
|
|
||||||
# must set it.
|
|
||||||
install_script_url() {
|
|
||||||
local host="${RIG_HOST:-https://github.com}"
|
|
||||||
host="${host%/}"
|
|
||||||
case "$host" in
|
|
||||||
https://github.com|http://github.com|*//github.com)
|
|
||||||
printf 'https://raw.githubusercontent.com/%s/main/install.sh\n' "${REPO:-heavy-duty/rig}" ;;
|
|
||||||
*)
|
|
||||||
printf '%s/%s/raw/branch/main/install.sh\n' "$host" "${REPO:-heavy-duty/rig}" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# The registry's candidate URLs, forge-aware — a byte-identical copy of
|
|
||||||
# commands/lib/templates.sh's, diffed by test/cli.sh so the two cannot drift
|
|
||||||
# (#109; the valid_version / warn_bootstrapped precedent). One decision about
|
|
||||||
# where a registry lives, made in one grammar: a snapshot fetched from a forge
|
|
||||||
# converge would never fetch from is worse than no snapshot at all.
|
|
||||||
#
|
|
||||||
# Copied rather than sourced on purpose. snapshot_templates runs against an
|
|
||||||
# extracted tree, so sourcing WOULD work here — but it would make the
|
|
||||||
# installer's behaviour depend on executing code from the tarball it just
|
|
||||||
# downloaded, ahead of any of it being installed. The installer reads that
|
|
||||||
# tree (sed for the pin); it does not run it.
|
|
||||||
templates_archive_urls() {
|
|
||||||
local host="${1%/}" repo="$2" ref="$3"
|
|
||||||
case "$host" in
|
|
||||||
https://github.com|http://github.com|*//github.com)
|
|
||||||
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref"
|
|
||||||
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref"
|
|
||||||
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
|
|
||||||
*)
|
|
||||||
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
|
|
||||||
esac
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- prerequisites -----------------------------------------------------------
|
# --- prerequisites -----------------------------------------------------------
|
||||||
|
|
@ -269,15 +200,12 @@ else
|
||||||
if [ -z "$REF" ]; then
|
if [ -z "$REF" ]; then
|
||||||
log "resolving the latest release of $REPO"
|
log "resolving the latest release of $REPO"
|
||||||
if ! REF="$(resolve_latest_tag "$REPO")"; then
|
if ! REF="$(resolve_latest_tag "$REPO")"; then
|
||||||
warn "could not resolve the latest release of $REPO — either no release exists yet, or ${RIG_HOST:-https://github.com} was unreachable."
|
warn "could not resolve the latest release of $REPO — either no release exists yet, or GitHub was unreachable."
|
||||||
warn "(install the development tree explicitly with RIG_REF=main when no release exists yet.)"
|
warn "(rig has no release until 0.1.0 is cut — rig#32. Until then, install the development tree explicitly.)"
|
||||||
die "set RIG_REF: e.g. curl -fsSL $(install_script_url) | RIG_REF=main bash"
|
die "set RIG_REF: e.g. curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | RIG_REF=main bash"
|
||||||
fi
|
fi
|
||||||
log "latest release: $REF"
|
log "latest release: $REF"
|
||||||
# Tag-only: the channel resolved a RELEASE tag, so the download is that
|
urls=("https://github.com/$REPO/archive/refs/tags/$REF.tar.gz")
|
||||||
# tag's archive and nothing else. Falling through to refs/heads would
|
|
||||||
# install a branch while INSTALLED_FROM still names the tag (#111 review).
|
|
||||||
mapfile -t urls < <(release_tag_url "$REPO" "$REF")
|
|
||||||
else
|
else
|
||||||
mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF")
|
mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF")
|
||||||
fi
|
fi
|
||||||
|
|
@ -293,7 +221,7 @@ else
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
[ -n "$got" ] \
|
[ -n "$got" ] \
|
||||||
|| die "failed to download $REPO@$REF — no candidate URL worked (host ${RIG_HOST:-https://github.com}; tried ${urls[*]})"
|
|| die "failed to download $REPO@$REF — not a tag and not a branch (tried refs/tags then refs/heads)"
|
||||||
|
|
||||||
log "extracting archive"
|
log "extracting archive"
|
||||||
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
|
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
|
||||||
|
|
@ -321,60 +249,6 @@ set_exec() { # $1 = a rig tree: the executable bits install.sh owns
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# snapshot_templates <rig-tree> — best-effort install-time cache of the exact
|
|
||||||
# registry pin carried by that tree. The pin remains the sole source of truth;
|
|
||||||
# the directory name makes a stale snapshot invisible after an upgrade.
|
|
||||||
# Failure is deliberately a warning: rig itself is still a complete install,
|
|
||||||
# and templates_resolve preserves the live-fetch fallback.
|
|
||||||
snapshot_templates() {
|
|
||||||
local tree="$1" pin repo host url got="" unpack top snapshot
|
|
||||||
pin="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$tree/commands/lib/templates.sh" 2>/dev/null | head -n1 || true)"
|
|
||||||
if [ -z "$pin" ]; then
|
|
||||||
warn "installed tree carries no RIG_TEMPLATES_PIN; template registry snapshot skipped."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
|
||||||
# The same forge knob templates_resolve reads, from the same variable (#109).
|
|
||||||
# Snapshot and live fetch MUST agree about where the registry lives: an
|
|
||||||
# install that cached from GitHub while converge fetches from Forgejo would
|
|
||||||
# serve a snapshot the pin never named, and the pin-in-the-directory-name
|
|
||||||
# staleness guard cannot catch a WRONG-ORIGIN snapshot, only an old one.
|
|
||||||
host="${RIG_TEMPLATES_HOST:-https://github.com}"
|
|
||||||
snapshot="$tree/templates@$pin"
|
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
|
||||||
warn "curl is unavailable; template registry snapshot $repo@$pin was not installed (converge will retry the live fetch)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
unpack="$TMPDIR/templates-unpack"
|
|
||||||
rm -rf "$unpack"
|
|
||||||
mkdir -p "$unpack"
|
|
||||||
log "downloading template registry snapshot ${host%/}/$repo@$pin"
|
|
||||||
while IFS= read -r url; do
|
|
||||||
if curl -fsSL "$url" -o "$TMPDIR/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
|
||||||
done < <(templates_archive_urls "$host" "$repo" "$pin")
|
|
||||||
if [ -z "$got" ]; then
|
|
||||||
warn "could not fetch template registry snapshot $repo@$pin; rig installed without it (converge will retry the live fetch)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if ! tar -xzf "$TMPDIR/templates.tar.gz" -C "$unpack"; then
|
|
||||||
warn "could not extract template registry snapshot from $got; rig installed without it (converge will retry the live fetch)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
set -- "$unpack"/*/
|
|
||||||
if ! { [ $# -eq 1 ] && [ -d "$1" ]; }; then
|
|
||||||
warn "template registry snapshot from $got has an unexpected archive shape; rig installed without it (converge will retry the live fetch)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
top="${1%/}"
|
|
||||||
if [ -z "$(find "$top" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)" ]; then
|
|
||||||
warn "template registry snapshot from $got has no definitions; rig installed without it (converge will retry the live fetch)."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
rm -rf "$snapshot"
|
|
||||||
mv "$top" "$snapshot"
|
|
||||||
log "template registry snapshot installed: $repo@$pin"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- install into $DEST/versions/<version> -----------------------------------
|
# --- install into $DEST/versions/<version> -----------------------------------
|
||||||
VDIR="$DEST/versions/$new_ver"
|
VDIR="$DEST/versions/$new_ver"
|
||||||
newly_installed=0
|
newly_installed=0
|
||||||
|
|
@ -385,7 +259,6 @@ if [ -d "$VDIR" ]; then
|
||||||
log "RIG_REINSTALL=1 — replacing the installed $new_ver tree"
|
log "RIG_REINSTALL=1 — replacing the installed $new_ver tree"
|
||||||
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
|
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
|
||||||
rm -rf "$stage" "$old"
|
rm -rf "$stage" "$old"
|
||||||
snapshot_templates "$EXTRACTED"
|
|
||||||
set_exec "$EXTRACTED"
|
set_exec "$EXTRACTED"
|
||||||
mv "$EXTRACTED" "$stage"
|
mv "$EXTRACTED" "$stage"
|
||||||
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
|
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
|
||||||
|
|
@ -403,7 +276,6 @@ if [ -d "$VDIR" ]; then
|
||||||
else
|
else
|
||||||
log "installing $new_ver into $VDIR"
|
log "installing $new_ver into $VDIR"
|
||||||
mkdir -p "$DEST/versions"
|
mkdir -p "$DEST/versions"
|
||||||
snapshot_templates "$EXTRACTED"
|
|
||||||
set_exec "$EXTRACTED"
|
set_exec "$EXTRACTED"
|
||||||
mv "$EXTRACTED" "$VDIR"
|
mv "$EXTRACTED" "$VDIR"
|
||||||
newly_installed=1
|
newly_installed=1
|
||||||
|
|
|
||||||
1178
test/cli.sh
1178
test/cli.sh
File diff suppressed because it is too large
Load diff
187
test/drill.sh
187
test/drill.sh
|
|
@ -50,10 +50,10 @@ trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
# --- the functions under test, extracted -------------------------------------
|
# --- the functions under test, extracted -------------------------------------
|
||||||
FNS="$WORK/drill-fns.sh"
|
FNS="$WORK/drill-fns.sh"
|
||||||
for fn in tree_of assert_installed_from classify_leg capture_state emit_record forgejo_run_verdict forgejo_token_verdict forgejo_max_task_id forgejo_leg_row; do
|
for fn in tree_of assert_installed_from classify_leg capture_state emit_record; do
|
||||||
awk "/^${fn}\(\) \{/,/^\}/" "$ROOT/drill/drill.sh" >> "$FNS"
|
awk "/^${fn}\(\) \{/,/^\}/" "$ROOT/drill/drill.sh" >> "$FNS"
|
||||||
done
|
done
|
||||||
for fn in tree_of assert_installed_from classify_leg capture_state emit_record forgejo_run_verdict forgejo_token_verdict forgejo_max_task_id forgejo_leg_row; do
|
for fn in tree_of assert_installed_from classify_leg capture_state emit_record; do
|
||||||
check "extraction guards the awk: ${fn}() landed" 0 "${fn}() {" grep -F "${fn}() {" "$FNS"
|
check "extraction guards the awk: ${fn}() landed" 0 "${fn}() {" grep -F "${fn}() {" "$FNS"
|
||||||
done
|
done
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
|
|
@ -159,7 +159,7 @@ check "…and the diff names the drifted sshd keyword, not just 'differs'" 1 "pa
|
||||||
emit() { # emit <outfile> — emit_record with the harness globals staged
|
emit() { # emit <outfile> — emit_record with the harness globals staged
|
||||||
DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \
|
DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \
|
||||||
REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \
|
REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \
|
||||||
TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" TPL_SOURCE="snapshot" \
|
TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" \
|
||||||
bash -c '
|
bash -c '
|
||||||
. "$1"
|
. "$1"
|
||||||
pass=12 fail=1 skipped=1
|
pass=12 fail=1 skipped=1
|
||||||
|
|
@ -174,7 +174,7 @@ check "record: the version-and-date heading" 0 "# Release drill — 9.9.9 — "
|
||||||
check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md"
|
check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md"
|
||||||
check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md"
|
check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md"
|
||||||
check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md"
|
check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md"
|
||||||
check "record: the template registry SHA and actual source ride alongside the pair (#110/#153)" 0 "rig-templates@9f8e7d6 (ref 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432, snapshot)" cat "$WORK/record.md"
|
check "record: the template registry SHA rides alongside the pair (#110)" 0 "rig-templates@9f8e7d6 (ref 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432)" cat "$WORK/record.md"
|
||||||
check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md"
|
check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md"
|
||||||
check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md"
|
check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md"
|
||||||
check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md"
|
check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md"
|
||||||
|
|
@ -201,177 +201,6 @@ check "an all-green record says every leg ran and passed" 0 "Every leg ran and e
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# the shipped script itself
|
# the shipped script itself
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# =============================================================================
|
|
||||||
# forgejo_run_verdict — did OUR dispatched run land, and how (#129)
|
|
||||||
# =============================================================================
|
|
||||||
# The Forgejo half of the runner leg cannot reuse the GitHub reader. Measured
|
|
||||||
# against forgejo.heavyduty.builders (8.0.3+gitea-1.22.0) on 2026-07-30, a
|
|
||||||
# completed run in GET /repos/{o}/{r}/actions/tasks carries NO `conclusion`
|
|
||||||
# field at all — `status` holds the terminal outcome directly, where GitHub
|
|
||||||
# splits status:completed + conclusion:success. And `id` is a global task id
|
|
||||||
# (25) while the run's own URL ends in run_number (1), so the pre-dispatch
|
|
||||||
# guard has to compare `id`.
|
|
||||||
#
|
|
||||||
# The payload is also an ASSIGNED-task view: it reads total_count 0 for as
|
|
||||||
# long as a run sits queued (measured: 200s), so "no new id" is the ONLY
|
|
||||||
# signal that the runner never took the job. That is the verdict this leg
|
|
||||||
# exists to produce, which is why it gets its own function and its own tests.
|
|
||||||
FJ="$WORK/fj"; mkdir -p "$FJ"
|
|
||||||
printf '%s' '{"workflow_runs":[],"total_count":0}' > "$FJ/empty.json"
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":25,"status":"success","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-success.json"
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":25,"status":"failure","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-failure.json"
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":25,"status":"cancelled","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-cancelled.json"
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":24,"status":"success","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/stale-only.json"
|
|
||||||
|
|
||||||
check "verdict: an empty task list is PENDING, never a pass" 0 "pending" \
|
|
||||||
forgejo_run_verdict "" "$FJ/empty.json"
|
|
||||||
check "verdict: a queued run the runner never took stays PENDING" 0 "pending" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/stale-only.json"
|
|
||||||
check "verdict: OUR new run, status success, is SUCCESS" 0 "success" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/new-success.json"
|
|
||||||
check "verdict: status carries the outcome — failure is FAILED, not success" 0 "failed" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/new-failure.json"
|
|
||||||
check "verdict: a cancelled run is FAILED, not silently passed" 0 "failed" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/new-cancelled.json"
|
|
||||||
check "verdict: the first run ever (no pre-id) still resolves" 0 "success" \
|
|
||||||
forgejo_run_verdict "" "$FJ/new-success.json"
|
|
||||||
# A task appears in this payload the moment it is ASSIGNED, which can be before
|
|
||||||
# it finishes — so a non-terminal status must read as pending, not as a failure.
|
|
||||||
# Calling a still-running job "failed" would make the leg flaky in exactly the
|
|
||||||
# window the leg is watching.
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":25,"status":"running","run_number":1}],"total_count":1}' > "$FJ/new-running.json"
|
|
||||||
check "verdict: an assigned-but-running task is PENDING, not FAILED" 0 "pending" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/new-running.json"
|
|
||||||
|
|
||||||
# grok/kimi on !130: the reader must not stop at the FIRST run. actions/tasks
|
|
||||||
# accumulates — the moment a repo is drilled twice, our run shares the payload
|
|
||||||
# with older ones, and nothing documents the sort order. Reading entry[0] makes
|
|
||||||
# a green job read as a timeout, which is a FALSE FAILURE on the very gate this
|
|
||||||
# leg exists to provide.
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":24,"status":"success"},{"id":25,"status":"success"}],"total_count":2}' > "$FJ/oldest-first.json"
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":25,"status":"success"},{"id":24,"status":"success"}],"total_count":2}' > "$FJ/newest-first.json"
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":25,"status":"running"},{"id":26,"status":"success"}],"total_count":2}' > "$FJ/ours-not-first.json"
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":23,"status":"success"},{"id":24,"status":"failure"}],"total_count":2}' > "$FJ/all-stale.json"
|
|
||||||
# Pretty-printed: the instance may or may not compact its JSON, and a parser
|
|
||||||
# that silently depends on one-line objects is a latent failure (kimi, !130).
|
|
||||||
# Written HERE, like every other fixture: a suite that copies from a scratch
|
|
||||||
# path passes only on the box that built it (grok/kimi, !130 round 2).
|
|
||||||
printf '%s\n' '{
|
|
||||||
"workflow_runs": [
|
|
||||||
{"id": 24, "status": "success"},
|
|
||||||
{"id": 25, "name": "drill", "status": "success"}
|
|
||||||
],
|
|
||||||
"total_count": 2
|
|
||||||
}' > "$FJ/pretty.json"
|
|
||||||
check "verdict: ours is LAST in the payload — order must not decide" 0 "success" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/oldest-first.json"
|
|
||||||
check "verdict: ours is FIRST in the payload — same answer" 0 "success" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/newest-first.json"
|
|
||||||
check "verdict: a stale RUNNING entry ahead of ours does not mask it" 0 "success" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/ours-not-first.json"
|
|
||||||
check "verdict: every entry at or below pre is stale — PENDING" 0 "pending" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/all-stale.json"
|
|
||||||
check "verdict: a pretty-printed payload parses too" 0 "success" \
|
|
||||||
forgejo_run_verdict "24" "$FJ/pretty.json"
|
|
||||||
|
|
||||||
# The PRE-DISPATCH snapshot has the same multi-entry hazard as the verdict, and
|
|
||||||
# getting it wrong is worse: a `head -n1` pre-id on an oldest-first payload
|
|
||||||
# names an OLD run as the baseline, so a later poll that finds the same body
|
|
||||||
# reports the PREVIOUS drill's run as ours — a false PASS on the take-a-job
|
|
||||||
# assertion, where the entry[0] bug only produced a false failure (grok, !130).
|
|
||||||
# Both sides must fold max over every id, which is why they share one function.
|
|
||||||
check "max id: oldest-first payload yields the NEWEST id, not the first" 0 "25" \
|
|
||||||
forgejo_max_task_id "$FJ/oldest-first.json"
|
|
||||||
check "max id: newest-first payload yields the same answer" 0 "25" \
|
|
||||||
forgejo_max_task_id "$FJ/newest-first.json"
|
|
||||||
check "max id: an empty payload has no id at all" 0 "" \
|
|
||||||
forgejo_max_task_id "$FJ/empty.json"
|
|
||||||
check "max id: a pretty-printed payload folds too" 0 "25" \
|
|
||||||
forgejo_max_task_id "$FJ/pretty.json"
|
|
||||||
# The false PASS, pinned end to end: snapshot the oldest-first body, dispatch,
|
|
||||||
# the runner never takes it so the body is unchanged — the verdict must stay
|
|
||||||
# pending. With head -n1 this returned success.
|
|
||||||
# The two halves composed exactly as the leg composes them.
|
|
||||||
verdict_after_no_new_run() { forgejo_run_verdict "$(forgejo_max_task_id "$1")" "$1"; }
|
|
||||||
check "no new run after dispatch: max-id baseline keeps it PENDING (false-PASS guard)" 0 "pending" \
|
|
||||||
verdict_after_no_new_run "$FJ/oldest-first.json"
|
|
||||||
check "…and the same composition on a pretty payload" 0 "pending" \
|
|
||||||
verdict_after_no_new_run "$FJ/pretty.json"
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# forgejo_leg_row — the row is the WHOLE lifecycle, not just the job
|
|
||||||
# =============================================================================
|
|
||||||
# codex/grok/kimi on !130: keying the record row on the take-a-job outcome alone
|
|
||||||
# lets it read "PASS — registered, took a job, removed" when install failed, so
|
|
||||||
# long as SOMETHING answered runs-on: drill. That is not contrived — this leg
|
|
||||||
# removes locally and tells the operator to delete the stale runner by hand, so
|
|
||||||
# a leftover drill-labeled runner from the previous drill is the DESIGNED-FOR
|
|
||||||
# aftermath, and it answers the fixture exactly.
|
|
||||||
#
|
|
||||||
# drills/<v>.md is the release's durable evidence. A row claiming a lifecycle
|
|
||||||
# that did not happen is precisely what the gate exists to refuse, so PASS
|
|
||||||
# requires every assertion, not just the interesting one.
|
|
||||||
check "leg row: everything succeeded is the only PASS" 0 "PASS" \
|
|
||||||
forgejo_leg_row 1 1 success 1 1
|
|
||||||
check "leg row: install failed cannot PASS, even when a foreign runner took the job" 0 "FAIL" \
|
|
||||||
forgejo_leg_row 0 1 success 1 1
|
|
||||||
check "leg row: status failed cannot PASS either" 0 "FAIL" \
|
|
||||||
forgejo_leg_row 1 0 success 1 1
|
|
||||||
check "leg row: remove failed cannot PASS" 0 "FAIL" \
|
|
||||||
forgejo_leg_row 1 1 success 0 1
|
|
||||||
check "leg row: a runner still registered after remove cannot PASS" 0 "FAIL" \
|
|
||||||
forgejo_leg_row 1 1 success 1 0
|
|
||||||
check "leg row: no dispatch attempted, everything else clean, is PARTIAL" 0 "PARTIAL" \
|
|
||||||
forgejo_leg_row 1 1 none 1 1
|
|
||||||
check "leg row: a job that was never taken is a FAIL" 0 "FAIL" \
|
|
||||||
forgejo_leg_row 1 1 timeout 1 1
|
|
||||||
check "leg row: PARTIAL requires a clean lifecycle too" 0 "FAIL" \
|
|
||||||
forgejo_leg_row 0 1 none 1 1
|
|
||||||
|
|
||||||
# The end-to-end shape codex/grok/kimi asked for, composed the way the leg
|
|
||||||
# composes it: a tasks payload carrying a NEWER successful run (as a foreign
|
|
||||||
# drill-labeled runner would produce) must still not yield a PASS row when the
|
|
||||||
# drill's own install failed. This is the exact false-evidence case.
|
|
||||||
row_after_failed_install() {
|
|
||||||
forgejo_leg_row 0 1 "$(forgejo_run_verdict "$(forgejo_max_task_id "$1")" "$1")" 1 1
|
|
||||||
}
|
|
||||||
printf '%s' '{"workflow_runs":[{"id":24,"status":"success"},{"id":99,"status":"success"}],"total_count":2}' \
|
|
||||||
> "$FJ/foreign-runner-took-it.json"
|
|
||||||
check "install failed + a newer successful run in the payload is still FAIL, never PASS" 0 "FAIL" \
|
|
||||||
row_after_failed_install "$FJ/foreign-runner-took-it.json"
|
|
||||||
# …and the same payload with a clean lifecycle is the PASS, so the check above
|
|
||||||
# is discriminating rather than always-FAIL.
|
|
||||||
row_after_clean_install() {
|
|
||||||
forgejo_leg_row 1 1 "$(forgejo_run_verdict "" "$1")" 1 1
|
|
||||||
}
|
|
||||||
check "…while the same payload with a clean lifecycle does PASS" 0 "PASS" \
|
|
||||||
row_after_clean_install "$FJ/foreign-runner-took-it.json"
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# forgejo_token_verdict — a configured leg that cannot mint must FAIL, not SKIP
|
|
||||||
# =============================================================================
|
|
||||||
# #129's own acceptance: "Token source present but the instance is unreachable
|
|
||||||
# -> the leg FAILS; it must not skip and must not pass". A mint that returns
|
|
||||||
# nothing because the instance is unreachable, the token is under-scoped or the
|
|
||||||
# repo name is wrong is a CONFIGURED leg failing — reporting "no token source"
|
|
||||||
# sends the operator to check an env var they already set, and writes SKIPPED
|
|
||||||
# where the record owes a FAIL. That is the UNREADABLE-vs-NONE shape
|
|
||||||
# drills/README.md names.
|
|
||||||
check "token: a resolved registration token is ok" 0 "ok" \
|
|
||||||
forgejo_token_verdict "reg-tok" ""
|
|
||||||
check "token: an explicit token wins even with no API token" 0 "ok" \
|
|
||||||
forgejo_token_verdict "reg-tok" ""
|
|
||||||
check "token: no token at all and no API token is a genuine SKIP" 0 "no-source" \
|
|
||||||
forgejo_token_verdict "" ""
|
|
||||||
check "token: API token offered but mint produced nothing is a FAILURE" 0 "mint-failed" \
|
|
||||||
forgejo_token_verdict "" "api-tok"
|
|
||||||
|
|
||||||
# The anti-false-positive guard, stated as its own case: an OLD completed run
|
|
||||||
# with the SAME id as pre_id must never be read as this dispatch's result.
|
|
||||||
check "verdict: a pre-existing success with the pre-id is NOT our run" 0 "pending" \
|
|
||||||
forgejo_run_verdict "25" "$FJ/new-success.json"
|
|
||||||
|
|
||||||
|
|
||||||
# Arg refusals fire before the root check (repo doctrine, bootstrap.sh:114),
|
# Arg refusals fire before the root check (repo doctrine, bootstrap.sh:114),
|
||||||
# which is what makes them provable here without a throwaway machine.
|
# which is what makes them provable here without a throwaway machine.
|
||||||
check "drill.sh refuses to run without BOTH refs pinned (#103)" 2 "--box-ref" \
|
check "drill.sh refuses to run without BOTH refs pinned (#103)" 2 "--box-ref" \
|
||||||
|
|
@ -388,14 +217,6 @@ check "an unknown flag dies loudly, exit 2" 2 "unknown option" \
|
||||||
bash "$ROOT/drill/drill.sh" --frobnicate
|
bash "$ROOT/drill/drill.sh" --frobnicate
|
||||||
check "--help prints the header and exits 0" 0 "THROWAWAY" \
|
check "--help prints the header and exits 0" 0 "THROWAWAY" \
|
||||||
bash "$ROOT/drill/drill.sh" --help
|
bash "$ROOT/drill/drill.sh" --help
|
||||||
check "--forgejo-instance is a known flag (the leg's opt-in)" 2 "--users <path> is required" \
|
|
||||||
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-instance https://f.example.com --yes
|
|
||||||
check "--forgejo-runner-repo is a known flag" 2 "--users <path> is required" \
|
|
||||||
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-runner-repo o/r --yes
|
|
||||||
check "--help names the forgejo runner leg's flags" 0 "--forgejo-instance" \
|
|
||||||
bash "$ROOT/drill/drill.sh" --help
|
|
||||||
check "--forgejo-ref is a known flag (Forgejo's dispatch needs a ref)" 2 "--users <path> is required" \
|
|
||||||
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-ref dev --yes
|
|
||||||
|
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "$PASS passed, $FAIL failed"
|
echo "$PASS passed, $FAIL failed"
|
||||||
|
|
|
||||||
|
|
@ -65,18 +65,6 @@ VER="$(cat "$ROOT/VERSION")"
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
# install.sh snapshots the pinned registry even for the local source channel.
|
|
||||||
# Serve a deterministic archive so this lifecycle remains fully offline.
|
|
||||||
SNAPBIN="$WORK/snapshot-bin"
|
|
||||||
mkdir -p "$SNAPBIN" "$WORK/snapshot-stage/rig-templates-pin/test-box"
|
|
||||||
printf 'USER="test"\n' > "$WORK/snapshot-stage/rig-templates-pin/test-box/template.env"
|
|
||||||
tar -czf "$WORK/snapshot.tar.gz" -C "$WORK/snapshot-stage" rig-templates-pin
|
|
||||||
cat > "$SNAPBIN/curl" <<'CURLEOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
cp "${SNAPSHOT_TARBALL:?}" "$4"
|
|
||||||
CURLEOF
|
|
||||||
chmod +x "$SNAPBIN/curl"
|
|
||||||
|
|
||||||
# tree_state <root> — what "changed nothing" must mean: every file's bytes,
|
# tree_state <root> — what "changed nothing" must mean: every file's bytes,
|
||||||
# every path's type and mode, every symlink's target. Beat 3 captures this
|
# every path's type and mode, every symlink's target. Beat 3 captures this
|
||||||
# before and after the re-run and diffs the two.
|
# before and after the re-run and diffs the two.
|
||||||
|
|
@ -136,10 +124,7 @@ check "honesty: a really-gone path passes the absence assert" 0 "" \
|
||||||
# RIG_INSTALL_SOURCE is the supported local channel (its contract — dir,
|
# RIG_INSTALL_SOURCE is the supported local channel (its contract — dir,
|
||||||
# tarball, loud refusal, no silent download fallback — is test/release.sh's);
|
# tarball, loud refusal, no silent download fallback — is test/release.sh's);
|
||||||
# in CI $ROOT is $GITHUB_WORKSPACE, so what lands is the code under review.
|
# in CI $ROOT is $GITHUB_WORKSPACE, so what lands is the code under review.
|
||||||
b1() {
|
b1() { RIG_INSTALL_SOURCE="$ROOT" bash "$ROOT/install.sh"; }
|
||||||
PATH="$SNAPBIN:$PATH" SNAPSHOT_TARBALL="$WORK/snapshot.tar.gz" \
|
|
||||||
RIG_INSTALL_SOURCE="$ROOT" bash "$ROOT/install.sh"
|
|
||||||
}
|
|
||||||
check "beat 1: install.sh installs this checkout" 0 "done" b1
|
check "beat 1: install.sh installs this checkout" 0 "done" b1
|
||||||
|
|
||||||
# --- beat 2: assert what landed ----------------------------------------------
|
# --- beat 2: assert what landed ----------------------------------------------
|
||||||
|
|
@ -155,9 +140,6 @@ check "beat 2: rig --version answers through the whole chain" 0 "rig $VER" \
|
||||||
"$BINDIR/rig" --version
|
"$BINDIR/rig" --version
|
||||||
check "beat 2: INSTALLED_FROM names the local source" 0 "local:$ROOT" \
|
check "beat 2: INSTALLED_FROM names the local source" 0 "local:$ROOT" \
|
||||||
cat "$DEST/versions/$VER/INSTALLED_FROM"
|
cat "$DEST/versions/$VER/INSTALLED_FROM"
|
||||||
TPL_PIN="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$ROOT/commands/lib/templates.sh")"
|
|
||||||
check "beat 2: pinned registry snapshot landed in the version tree" 0 "" \
|
|
||||||
test -f "$DEST/versions/$VER/templates@$TPL_PIN/test-box/template.env"
|
|
||||||
|
|
||||||
# --- beat 3: the converging re-run -------------------------------------------
|
# --- beat 3: the converging re-run -------------------------------------------
|
||||||
# "Ran twice without crashing" is the self-deception this beat exists to
|
# "Ran twice without crashing" is the self-deception this beat exists to
|
||||||
|
|
|
||||||
112
test/release.sh
112
test/release.sh
|
|
@ -42,14 +42,8 @@ FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||||
# inline; extract them here and drive them for real (the valid_version awk
|
# inline; extract them here and drive them for real (the valid_version awk
|
||||||
# idiom from test/cli.sh), against a stub curl — never the network.
|
# idiom from test/cli.sh), against a stub curl — never the network.
|
||||||
RL="$WORK/installer-fns.sh"
|
RL="$WORK/installer-fns.sh"
|
||||||
# Grouped redirect — shellcheck SC2129 flags four individual >> to the same
|
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$RL"
|
||||||
# file (crossed the threshold when release_tag_url joined the extract set).
|
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL"
|
||||||
{
|
|
||||||
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh"
|
|
||||||
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh"
|
|
||||||
awk '/^release_tag_url\(\) \{/,/^\}/' "$ROOT/install.sh"
|
|
||||||
awk '/^install_script_url\(\) \{/,/^\}/' "$ROOT/install.sh"
|
|
||||||
} > "$RL"
|
|
||||||
check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL"
|
check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL"
|
||||||
|
|
||||||
STUB="$WORK/stub"; mkdir -p "$STUB"
|
STUB="$WORK/stub"; mkdir -p "$STUB"
|
||||||
|
|
@ -99,57 +93,14 @@ check "resolve: a tagless releases/tag/ redirect fails" 1 "" \
|
||||||
check "resolve: a failing curl fails (network down is not a channel)" 1 "" \
|
check "resolve: a failing curl fails (network down is not a channel)" 1 "" \
|
||||||
rlt CURL_STUB_FAIL=1
|
rlt CURL_STUB_FAIL=1
|
||||||
|
|
||||||
rcu_line() { # rcu_line <n> [VAR=val ...] — the nth candidate URL for an explicit ref
|
rcu_line() { # rcu_line <n> — the nth candidate URL for an explicit ref
|
||||||
local n="$1"; shift
|
bash -c 'set -euo pipefail
|
||||||
# shellcheck disable=SC2016
|
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$1"
|
||||||
env "$@" bash -c 'set -euo pipefail
|
|
||||||
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$n"
|
|
||||||
}
|
}
|
||||||
check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \
|
check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \
|
||||||
"https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1
|
"https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1
|
||||||
check "candidates: refs/heads is the fallback" 0 \
|
check "candidates: refs/heads is the fallback" 0 \
|
||||||
"https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2
|
"https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2
|
||||||
# RIG_HOST is host-only (#111): Forgejo serves the same refs/{tags,heads}/
|
|
||||||
# pair (measured 2026-07-29). Host substituted; grammar unchanged.
|
|
||||||
check "candidates: Forgejo host uses the same refs/tags form" 0 \
|
|
||||||
"https://forgejo.example/acme/widgets/archive/refs/tags/1.2.3.tar.gz" \
|
|
||||||
rcu_line 1 RIG_HOST=https://forgejo.example
|
|
||||||
check "candidates: Forgejo host keeps refs/heads as fallback" 0 \
|
|
||||||
"https://forgejo.example/acme/widgets/archive/refs/heads/1.2.3.tar.gz" \
|
|
||||||
rcu_line 2 RIG_HOST=https://forgejo.example
|
|
||||||
# shellcheck disable=SC2016
|
|
||||||
check "candidates: Forgejo host emits exactly two candidates" 0 "2" \
|
|
||||||
env RIG_HOST=https://forgejo.example bash -c 'set -euo pipefail
|
|
||||||
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | grep -c .' _ "$RL"
|
|
||||||
check "candidates: trailing slash on RIG_HOST is stripped" 0 \
|
|
||||||
"https://forgejo.example/acme/widgets/archive/refs/tags/1.2.3.tar.gz" \
|
|
||||||
rcu_line 1 RIG_HOST=https://forgejo.example/
|
|
||||||
|
|
||||||
# install_script_url — the curl|bash hint must match the forge (#111).
|
|
||||||
isu() {
|
|
||||||
# shellcheck disable=SC2016
|
|
||||||
env "$@" bash -c 'set -euo pipefail
|
|
||||||
REPO=heavy-duty/rig; . "$1"; install_script_url' _ "$RL"
|
|
||||||
}
|
|
||||||
check "install_script_url: GitHub default uses raw.githubusercontent.com" 0 \
|
|
||||||
"https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh" isu
|
|
||||||
check "install_script_url: Forgejo uses /raw/branch/main/" 0 \
|
|
||||||
"https://forgejo.example/heavy-duty/rig/raw/branch/main/install.sh" \
|
|
||||||
isu RIG_HOST=https://forgejo.example
|
|
||||||
|
|
||||||
# resolve_latest_tag follows RIG_HOST too — the probe URL must name the forge.
|
|
||||||
rlt_log="$WORK/rlt-log"
|
|
||||||
: > "$rlt_log"
|
|
||||||
check "resolve: RIG_HOST is the releases/latest origin" 0 "0.2.0" \
|
|
||||||
rlt CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases/tag/0.2.0 \
|
|
||||||
RIG_HOST=https://forgejo.example CURL_STUB_LOG="$rlt_log"
|
|
||||||
check "resolve: the probe hit the Forgejo host" 0 \
|
|
||||||
"https://forgejo.example/heavy-duty/rig/releases/latest" \
|
|
||||||
cat "$rlt_log"
|
|
||||||
# Forgejo's no-release path is a 404 (not GitHub's /releases redirect) —
|
|
||||||
# curl -f fails and || return 1 fires. Drive that branch under RIG_HOST.
|
|
||||||
check "resolve: Forgejo no-release is a failing curl (404), not a /releases redirect" 1 "" \
|
|
||||||
rlt RIG_HOST=https://forgejo.example CURL_STUB_FAIL=1
|
|
||||||
|
|
||||||
# --- the three channels, driven through the REAL installer -------------------
|
# --- the three channels, driven through the REAL installer -------------------
|
||||||
# Full install.sh runs against throwaway roots with the stub curl on PATH: the
|
# Full install.sh runs against throwaway roots with the stub curl on PATH: the
|
||||||
|
|
@ -193,27 +144,6 @@ check "channel latest: the refusal says what is missing" 1 "no release" \
|
||||||
rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
|
rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
|
||||||
check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2"
|
check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2"
|
||||||
|
|
||||||
# Channel 1, regression — a resolved tag whose archive is gone must FAIL,
|
|
||||||
# never fall through to refs/heads/<tag> and still claim the release
|
|
||||||
# (claude REQUEST_CHANGES on !114: INSTALLED_FROM would name the tag for a
|
|
||||||
# branch tree). CURL_STUB_OK only matches heads — if the installer tries it,
|
|
||||||
# the install would succeed and this check would fail.
|
|
||||||
H2b="$WORK/h2b"; B2b="$WORK/b2b"; LOG2b="$WORK/log2b"
|
|
||||||
check "channel latest: missing tag archive does NOT fall through to heads" \
|
|
||||||
1 "no candidate URL worked" rinst "$H2b" "$B2b" \
|
|
||||||
CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/3.3.3 \
|
|
||||||
CURL_STUB_OK=refs/heads/3.3.3 CURL_STUB_LOG="$LOG2b"
|
|
||||||
check "channel latest: ...and installed NOTHING (branch was never taken)" 1 "" \
|
|
||||||
test -e "$H2b"
|
|
||||||
# The log also holds the releases/latest probe; the download tries are the
|
|
||||||
# archive URLs. Exactly one archive try, and it is refs/tags — never heads.
|
|
||||||
check "channel latest: ...exactly one archive URL was tried" 0 "1" \
|
|
||||||
grep -c '/archive/' "$LOG2b"
|
|
||||||
check "channel latest: ...that try was refs/tags" 0 "refs/tags/3.3.3" \
|
|
||||||
cat "$LOG2b"
|
|
||||||
check "channel latest: ...refs/heads was never consulted" 1 "" \
|
|
||||||
grep -q 'refs/heads/' "$LOG2b"
|
|
||||||
|
|
||||||
# Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is
|
# Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is
|
||||||
# never consulted (a pin resolves nothing).
|
# never consulted (a pin resolves nothing).
|
||||||
H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3"
|
H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3"
|
||||||
|
|
@ -237,36 +167,8 @@ check "channel dev: ...then the branch URL" 0 "refs/heads/feature-x" \
|
||||||
|
|
||||||
# Neither a tag nor a branch: both candidates miss, and the die says so.
|
# Neither a tag nor a branch: both candidates miss, and the die says so.
|
||||||
H5="$WORK/h5"; B5="$WORK/b5"
|
H5="$WORK/h5"; B5="$WORK/b5"
|
||||||
check "channel: a ref that is neither tag nor branch dies naming the tries" \
|
check "channel: a ref that is neither tag nor branch dies naming both tries" \
|
||||||
1 "no candidate URL worked" rinst "$H5" "$B5" RIG_REF=no-such-ref
|
1 "not a tag and not a branch" rinst "$H5" "$B5" RIG_REF=no-such-ref
|
||||||
|
|
||||||
# Channel 4 — RIG_HOST=Forgejo: same refs/tags→refs/heads candidate order and
|
|
||||||
# the same /releases/latest redirect grammar (#111). The stub succeeds only
|
|
||||||
# when the refs/tags form is requested — a regression that still emitted the
|
|
||||||
# bare /archive/<ref> form would fail here.
|
|
||||||
H9="$WORK/h9"; B9="$WORK/b9"; LOG9="$WORK/log9"
|
|
||||||
check "channel forgejo latest: resolves and installs via refs/tags archive URL" 0 "done" \
|
|
||||||
rinst "$H9" "$B9" RIG_HOST=https://forgejo.example \
|
|
||||||
CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases/tag/7.7.7-relflow \
|
|
||||||
CURL_STUB_OK='/archive/refs/tags/7.7.7-relflow.tar.gz' CURL_STUB_LOG="$LOG9"
|
|
||||||
check "channel forgejo latest: download URL is the refs/tags form" 0 \
|
|
||||||
"https://forgejo.example/heavy-duty/rig/archive/refs/tags/7.7.7-relflow.tar.gz" \
|
|
||||||
cat "$LOG9"
|
|
||||||
check "channel forgejo latest: the tree landed" 0 "" \
|
|
||||||
test -x "$H9/versions/7.7.7-relflow/bin/rig"
|
|
||||||
H10="$WORK/h10"; B10="$WORK/b10"
|
|
||||||
check "channel forgejo pinned: RIG_REF=main falls through to refs/heads" 0 "done" \
|
|
||||||
rinst "$H10" "$B10" RIG_HOST=https://forgejo.example RIG_REF=main \
|
|
||||||
CURL_STUB_OK='/archive/refs/heads/main.tar.gz'
|
|
||||||
check "channel forgejo pinned: the tree landed" 0 "" \
|
|
||||||
test -x "$H10/versions/7.7.7-relflow/bin/rig"
|
|
||||||
# Refusal hint on a non-GitHub host must not send the operator to
|
|
||||||
# raw.githubusercontent.com (that 404s from a Forgejo-only tree).
|
|
||||||
H11="$WORK/h11"; B11="$WORK/b11"
|
|
||||||
check "channel forgejo latest: no-release hint uses the Forgejo raw URL" \
|
|
||||||
1 "https://forgejo.example/heavy-duty/rig/raw/branch/main/install.sh" \
|
|
||||||
rinst "$H11" "$B11" RIG_HOST=https://forgejo.example \
|
|
||||||
CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases
|
|
||||||
|
|
||||||
# --- the local channel: RIG_INSTALL_SOURCE (#106) ----------------------------
|
# --- the local channel: RIG_INSTALL_SOURCE (#106) ----------------------------
|
||||||
# A supported input, not test scaffolding — CI's `install:` job and test/cli.sh
|
# A supported input, not test scaffolding — CI's `install:` job and test/cli.sh
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue