Compare commits

..
43 Commits
Author SHA1 Message Date
yukkop e22dcdda80 feat: kanban: coments
runner nix smoke / nix label and flake smoke (push) Failing after 1m17s
2026-09-29 19:48:14 +00:00
yukkop c93dee27b3 feat: gitea: queries
runner nix smoke / nix label and flake smoke (push) Failing after 1m16s
2026-09-29 17:23:48 +00:00
yukkop 3f7856b4aa feat: update pz
runner nix smoke / nix label and flake smoke (push) Failing after 1m50s
2026-09-28 14:50:05 +00:00
yukkop 5b0ae6be5e feat: kanban cli
runner nix smoke / nix label and flake smoke (push) Failing after 1m20s
2026-09-28 09:25:04 +00:00
yukkop 54eb69d1c7 feat: clear unneccessary backend from kanban
runner nix smoke / nix label and flake smoke (push) Failing after 35s
2026-09-28 00:24:44 +00:00
yukkop 0cbf267474 feat: more crud for kanban
runner nix smoke / nix label and flake smoke (push) Failing after 2m6s
2026-09-28 00:08:44 +00:00
yukkop 335a74010d feat: kanban tui
runner nix smoke / nix label and flake smoke (push) Failing after 1m12s
2026-09-27 15:42:51 +00:00
yukkop 4c9611a2d2 some
runner nix smoke / nix label and flake smoke (push) Failing after 1m12s
2026-09-27 08:25:06 +00:00
yukkop 5681519b6f fix: global update
runner nix smoke / nix label and flake smoke (push) Failing after 30s
2026-09-26 22:01:43 +00:00
yukkop fd59158ed6 feat: update nixpkgs
runner nix smoke / nix label and flake smoke (push) Failing after 40s
2026-09-26 21:44:19 +00:00
yukkop d9b2a4e787 feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s
2026-09-26 21:18:24 +00:00
yukkop c439c1b948 fix: ssh extra debug
runner nix smoke / nix label and flake smoke (push) Failing after 57s
2026-09-26 19:11:41 +00:00
yukkop 0fe85c67d9 ci: staging server
runner nix smoke / nix label and flake smoke (push) Failing after 50s
2026-09-26 13:59:59 +00:00
yukkop 09eecf5969 feat: lfs
runner nix smoke / nix label and flake smoke (push) Failing after 1m59s
2026-09-24 23:15:43 +00:00
yukkop e444ea5936 fix: world-of-sosal
runner nix smoke / nix label and flake smoke (push) Failing after 59s
2026-09-23 19:26:19 +00:00
yukkop c917e4908c feat: zomboid backups
runner nix smoke / nix label and flake smoke (push) Failing after 54s
2026-09-22 17:15:18 +00:00
yukkop ef849b085f fix: wow-minecraft: finish configurations
runner nix smoke / nix label and flake smoke (push) Failing after 56s
2026-09-19 09:04:05 +00:00
yukkop a09f247290 fix: wow-minecraft: mirror on bfs.band
runner nix smoke / nix label and flake smoke (push) Failing after 22s
2026-09-19 08:27:33 +00:00
yukkop 3a52023082 fix: configure minecraft 2026-09-19 08:13:49 +00:00
yukkop 2bd466b652 feat: configure minecraft and zomboid
runner nix smoke / nix label and flake smoke (push) Failing after 1m13s
2026-09-19 07:37:19 +00:00
yukkop 83cf9ff32f .
runner nix smoke / nix label and flake smoke (push) Failing after 55s
2026-09-17 08:37:12 +00:00
yukkop a9e538fc76 feat: prism launcher link 2026-09-16 12:48:15 +00:00
yukkop 88ec8a59d8 feat: site lessons
runner nix smoke / nix label and flake smoke (push) Failing after 17s
2026-09-15 19:48:13 +00:00
yukkopandSisyphus 417820544e fix: raise runner monthly budget
runner nix smoke / nix label and flake smoke (push) Failing after 27s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-15 00:58:07 +00:00
yukkop 1cba36e76e feat: neuro: minecraft mods
runner nix smoke / nix label and flake smoke (push) Has been cancelled
2026-09-14 12:22:10 +00:00
yukkop 4f97fb3244 feat: neuro: new minecraft
runner nix smoke / nix label and flake smoke (push) Failing after 22s
2026-09-13 22:36:11 +00:00
yukkop 252d3418a5 fix: immitch 502
runner nix smoke / nix label and flake smoke (push) Failing after 1m15s
2026-09-13 21:49:57 +00:00
yukkop 96882fd8cd fix: immitch 502
runner nix smoke / nix label and flake smoke (push) Failing after 50s
2026-09-13 19:11:59 +00:00
yukkop 522010b7e6 fix: minecraft
runner nix smoke / nix label and flake smoke (push) Failing after 54s
2026-09-13 18:04:18 +00:00
yukkop 761120b211 fix: anitcheat in project zomboid
runner nix smoke / nix label and flake smoke (push) Failing after 1m21s
2026-09-13 12:58:01 +00:00
yukkop a04a58b792 fix: immich; feat: zomboid settings 2026-09-13 09:38:32 +00:00
yukkop 1e973e7b33 feat: configure immich
runner nix smoke / nix label and flake smoke (push) Failing after 25s
2026-09-12 22:21:33 +00:00
yukkop 66a502b1dd fix: project zomboid module
runner nix smoke / nix label and flake smoke (push) Failing after 1m24s
2026-09-12 21:56:31 +00:00
yukkopandSisyphus fce9ae07be fix: configure Dify Redis connection
runner nix smoke / nix label and flake smoke (push) Failing after 1m18s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 19:59:21 +00:00
yukkop a8c4a52a68 feat: olama fix
runner nix smoke / nix label and flake smoke (push) Failing after 1m22s
2026-09-12 18:57:21 +00:00
yukkop 078e01c87f feat: some
runner nix smoke / nix label and flake smoke (push) Failing after 55s
2026-09-11 20:20:57 +00:00
yukkop 6bc564de59 feat: some 2026-09-11 19:31:04 +00:00
yukkop cc8a7cf80e fix: reconcile healthy terminal runners
runner nix smoke / nix label and flake smoke (push) Failing after 28s
2026-09-11 09:19:04 +00:00
yukkop 279df769db fix: retain healthy terminal runners 2026-09-11 09:19:03 +00:00
yukkop 069b18daa3 fix: check runner health before reuse 2026-09-11 09:19:03 +00:00
yukkop 37bd69e90e fix: avoid Cargo metadata IFD 2026-09-11 09:19:03 +00:00
yukkopandSisyphus 572133a941 fix: avoid p4d init IFD
runner nix smoke / nix label and flake smoke (push) Failing after 59s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 08:36:59 +00:00
yukkopandSisyphus ed721dd961 fix: avoid smtp source IFD
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 08:36:46 +00:00
3617 changed files with 125690 additions and 44664 deletions
+2 -2
View File
@@ -98,13 +98,13 @@ jobs:
true)
nix run --refresh '.#with-attic-cache' -- -- \
nix run --refresh '.#deploy' -- \
push -- --flake '.#neuro|x86_64-linux' --target-host neuro --use-remote-sudo
push -- --flake '.#neuro|x86_64-linux' --target-host neuro
;;
false)
unset ATTIC_TOKEN
timeout --kill-after=60s 21600s \
nix run --refresh '.#deploy' -- \
push -- --flake '.#neuro|x86_64-linux' --target-host neuro --use-remote-sudo
push -- --flake '.#neuro|x86_64-linux' --target-host neuro
;;
*)
printf 'unsupported upload_cache value: %s\n' "$UPLOAD_CACHE" >&2
+8 -1
View File
@@ -6,12 +6,19 @@ keys:
- &bfs-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj
- &bfs-pol-server age1fpytf05sg9n6ywpwkmn09lhpfvgtud9h75h76jhxha475zpnasqq952rpu
- &bfs-new-server age17yx98qk9gzgcf2q6zhhp05p6mmtrkgz66dvyk9gqclypvlr8rersxjy5v7
- &neuro-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj
- &neuro-server age1ak7heljpr0pjr4m0rcwxgn3sp0jjxw03lxyf33r8lcemqh2u2sgqx0aplq
- &games-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj
- &hectic-lab-server age13h8twnwvgxn04l5ywtru89a6psw5d0uckr2eghxsjp88a5augvsstq5ard
- &umbriel-bfs age1jxntjca8q2vxvf2jaal4xyvm2ae6sh62fhv897694kuzawfrk5asj00zdt
creation_rules:
- path_regex: sus/neuro-minecraft.yaml$
key_groups:
- age:
- age1r25zdeqq8nac6dgca9en28r57ffyz9u9d8z5yc25gc8xqz747vaqmdtk0h
- age1ak7heljpr0pjr4m0rcwxgn3sp0jjxw03lxyf33r8lcemqh2u2sgqx0aplq
- age1ev53mzse6rg4ffwtcwtq4e93c7x7s4d0eyu89jrsahrke8r4yamsseu8h4
- path_regex: sus/home.xray.yaml$
key_groups:
- age:
+1
View File
@@ -5,6 +5,7 @@
postgres-c = import ./postgres-c.nix { inherit self system pkgs; };
pure-c = import ./pure-c.nix { inherit self system pkgs; };
rust = import ./rust.nix { inherit self system pkgs; };
ratatui = import ./ratatui.nix { inherit self system pkgs; };
haskell = import ./haskell.nix { inherit self system pkgs; };
neuro = import ./neuro.nix { inherit self system pkgs; };
xmpp = import ./xmpp.nix { inherit self system pkgs; };
+19
View File
@@ -0,0 +1,19 @@
{
self,
pkgs,
system
}: let
rustToolchain =
if builtins.pathExists ./rust-toolchain.toml
then pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml
else pkgs.pkgsBuildHost.rust-bin.stable."1.81.0".default;
in
self.devShells.${system}.default
// (pkgs.mkShell {
nativeBuildInputs = [
rustToolchain
pkgs.pkg-config
pkgs.gcc
pkgs.gnumake
];
})
+3
View File
@@ -1,6 +1,9 @@
# Documentation
- [Using the `hectic` Attic Cache](./attic-cache.md)
- [Minecraft incident log](./minecraft-incidents.md)
- [Project Zomboid backups](./project-zomboid-backups.md)
- [Gitea Kanban CLI](./gitea-kanban-cli.md)
## Gitea runner labels
+141
View File
@@ -0,0 +1,141 @@
# Gitea Kanban CLI
`gitea-kanban` is the non-interactive interface for native Gitea Projects.
It shares its configuration, API client, models, permissions, and operations
with `gitea-kanban-tui`.
## Installation
The Nix package installs both binaries:
```sh
nix build .#gitea-kanban-tui
./result/bin/gitea-kanban --help
./result/bin/gitea-kanban-tui --help
```
For development:
```sh
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml \
--bin gitea-kanban -- --help
```
## Configuration
Configuration can be supplied with flags or environment variables. A project
selector is required, and exactly one selector must be supplied.
| Flag | Environment | Description |
| --- | --- | --- |
| `--url` | `GITEA_URL` | Gitea base URL |
| `--token-file PATH` | `GITEA_TOKEN_FILE` | File containing API token |
| — | `GITEA_TOKEN` | Token fallback when no token file is configured |
| `--project NAME` | `GITEA_PROJECT` | Exact, case-sensitive project name |
| `--project-id ID` | `GITEA_PROJECT_ID` | Project ID; useful for duplicate names |
| `OWNER` | `GITEA_OWNER` | Repository owner |
| `REPO` | `GITEA_REPO` | Repository name |
Precedence:
1. Command-line flags and positional arguments.
2. Token file from `--token-file` or `GITEA_TOKEN_FILE`.
3. `GITEA_TOKEN` when no token file is configured.
Token files must be regular files and must not be group- or world-readable on
Unix. Remote URLs must use HTTPS. Plain HTTP is accepted only for literal
loopback IP addresses (`127.0.0.1` or `::1`) during local development.
Example:
```sh
export GITEA_URL=https://gitea.example
export GITEA_TOKEN_FILE="$HOME/.config/gitea/token"
gitea-kanban --project Kanban owner repo board
```
## Commands
### `board`
Print selected project, columns, and issues:
```sh
gitea-kanban --project Kanban owner repo board
```
Output includes issue numbers and titles. Server-provided terminal control
characters are removed before output.
### `create`
Create an issue and assign it to selected project. The issue starts in the
project's default column.
```sh
gitea-kanban --project Kanban owner repo create \
--title "Fix deployment" \
--body "Investigate failed rollout"
```
### `edit`
Replace issue title and body. Issue must be assigned to selected project.
```sh
gitea-kanban --project Kanban owner repo edit 42 \
--title "Updated title" \
--body "Updated description"
```
### `move`
Move an issue to a project column. `ISSUE_ID` is the global Gitea issue ID,
not the repository issue number. `--sorting` is optional.
```sh
gitea-kanban --project Kanban owner repo move 123 --column-id 7
gitea-kanban --project Kanban owner repo move 123 --column-id 7 --sorting 2
```
### `comment`
Comments are managed on issues assigned to the selected project. Issue values
are repository issue numbers. Comment values are global Gitea comment IDs.
```sh
gitea-kanban --project Kanban owner repo comment list 42
gitea-kanban --project Kanban owner repo comment add 42 --body "Investigating"
gitea-kanban --project Kanban owner repo comment edit 42 9001 --body "Resolved"
gitea-kanban --project Kanban owner repo comment delete 42 9001 --yes
```
The CLI verifies that the issue belongs to the selected project and that the
comment belongs to that issue before editing or deleting it.
### `delete`
Delete an issue assigned to selected project. This is irreversible and requires
explicit confirmation with `--yes`:
```sh
gitea-kanban --project Kanban owner repo delete 42 --yes
```
Deletion also requires repository administrator permission in this Gitea fork.
## Permissions and server behavior
- The repository Projects unit must be enabled.
- Project operations require repository Projects write permission.
- Issue create/edit follows Gitea issue permissions.
- Delete requires repository administrator permission.
- Closed projects, archived repositories, disabled Projects units, foreign
project IDs, and issues outside selected project are rejected.
- API failures return a non-zero exit status. Authentication and HTTP status
guidance is shown without printing server response bodies or tokens.
## Related interface
Use `gitea-kanban-tui` for keyboard navigation over the same native Projects
API. Both binaries use the same configuration and server-side authorization.
+101
View File
@@ -0,0 +1,101 @@
# Minecraft incident log
This file records only observed evidence, actions, and verification results.
An entity appearing in a stack trace is a trigger-path observation, not a
proven root cause.
## 2026-09-19 — WorldOfSosal crashes in Sable block-change handling
### Impact
- `minecraft-server-wowMineMap.service` terminates while a player is online.
- Public Minecraft endpoint is `store.hectic-lab.com:25568`.
- Server is intentionally stopped after the latest crash to prevent repeated
crash-save cycles while recovery is investigated.
### Observed evidence
All crash reports contain `sable@2.0.5` in
`LevelAccelerator.getBlockState`, followed by
`ArrayIndexOutOfBoundsException` where the requested section index exceeds
the world section array length of `24`.
| UTC timestamp | Crash report | Observed trigger path | Exception |
| --- | --- | --- | --- |
| 18:47:03 | `crash-2026-09-19_18.47.03-server.txt` | `EnderMan$EndermanTakeBlockGoal.tick` | index `38` / length `24` |
| 18:52:17 | `crash-2026-09-19_18.52.17-server.txt` | `GlowSquid.aiStep` → `RedStoneOreBlock.stepOn` | index `33` / length `24` |
| 19:14:46 | `crash-2026-09-19_19.14.46-server.txt` | `Skeleton.tick` → `RedStoneOreBlock.stepOn` | index `34` / length `24` |
Evidence locations on `neuro`:
```text
/srv/minecraft/wowMineMap/crash-reports/
/srv/minecraft/wowMineMap/logs/latest.log
```
### Actions performed
| UTC timestamp | Action | Result |
| --- | --- | --- |
| 17:51 | Archived current world before recovery | Archive checksum recorded |
| 18:08 | Set `randomTickSpeed=0` | Server started, but later crashed from an entity block change |
| 18:48 | Set `mobGriefing=false` | Prevented Enderman block pickup only; later crashes still occurred |
| 18:54 | Archived post-crash world | Archive checksum recorded |
| 19:00 | Moved Boss offline player NBT from `(3299.067, 142.630, 8613.742)` to `(3296, 500, 8608)` in `crafting_azeroth:azeroth` | Only `Pos` and `Dimension` changed; later crash still occurred |
| after 19:14 crash | Stopped `minecraft-server-wowMineMap.service` | Prevented further automatic crash/restart saves |
### Recovery artifacts
```text
/srv/minecraft/backups/wowMineMap-before-sable-recovery-20260919T175139Z.tar.zst
/srv/minecraft/backups/wowMineMap-after-sable-crashes-20260919T185445Z.tar.zst
/srv/minecraft/wowMineMap/world/playerdata/1c189af5-2713-3fa6-bcc4-893dfadedfa4.dat.before-relocation
```
### Conclusions supported by evidence
- Public proxy and reverse tunnel are not the failure point: server-list ping
succeeded before later in-world crashes.
- The failure is not limited to Endermen, random ticks, or one player
position.
- Sable's block-change callback is present in every captured crash.
### Not established
- Exact corrupt chunk, block, or mod data.
- Whether world data is corrupt, Sable itself is defective, or another mod is
supplying incompatible world state.
- Whether deleting any chunk, region, or Sable state would be safe.
### External research
No exact upstream match was found for Sable `2.0.5` on NeoForge `1.21.1` with
`LevelAccelerator.getBlockState` and a requested section index of `33`, `34`,
or `38` against a section array of length `24`.
Related but non-identical upstream reports:
- [Sable #776](https://github.com/ryanhcode/sable/issues/776) documents an
`ArrayIndexOutOfBoundsException` associated with unusual dimension height
bounds. This is relevant to section-coordinate handling, but is an older
version and different stack trace.
- [Sable #1087](https://github.com/ryanhcode/sable/issues/1087) documents a
`LevelAccelerator.getBlockState` recursion during block-shape processing.
The failure type differs.
- [Sable #820](https://github.com/ryanhcode/sable/issues/820) documents a
ticking-entity block-change crash. The reported downgrade to `1.1.3` helped
that distinct recursive-update failure; it is not evidence for this crash.
- [Sable #1223](https://github.com/ryanhcode/sable/issues/1223) documents a
different `ArrayIndexOutOfBoundsException` in voxel-neighborhood handling.
Its suggested Lithium setting only reduced crashes for some reporters and is
not a verified mitigation here.
Sable `2.0.4` and `2.0.5` release notes mention other block or contraption
crash fixes, but not this exception. No version upgrade or downgrade is
currently evidence-backed as a production fix.
### Next recovery step
Use a disposable full-world copy to test a supported Sable/physics integration
mitigation. Do not restart production, delete region files, or overwrite a
backup until that test gives reproducible evidence.
+231
View File
@@ -0,0 +1,231 @@
# WorldOfSosal: Prism automatic updates
The published client entry points are:
- https://store.bfs.band/minecraft/ (BFS / Element host)
- https://store.hectic-lab.com/minecraft/world-of-sosal/ (hectic-lab)
Each site provides its own Prism ZIP with that site's update URL and matching
server address. Both installs use the same Minecraft world and modpack release.
Players import `WorldOfSosal-Prism.zip` into Prism once and approve its pre-launch
command. Before each launch, packwiz-installer reconciles the client with the
published pack: it adds, replaces, and removes managed files, checking hashes.
`options.txt` is seeded once and preserved. Pack configuration files are managed
and can be replaced. Upstream mods do not update independently of your release.
Minecraft 1.21.1, NeoForge 21.1.250, Java 21; the instance reserves up to 8 GiB.
The original `.mrpack` alone does not provide this automatic update mechanism.
Official workflow: https://packwiz.infra.link/tutorials/installing/packwiz-installer/
## Publishing a tested update
Keep the authoritative `.mrpack` in Storage Box at
`minecraft/pack/WorldOfSosal.mrpack`. For a server update, replace that archive,
set its new SHA-256 in `nixos/system/neuro/minecraft/world-of-sosal.nix`,
and rebuild/switch neuro before publishing the corresponding client export. The server importer and the
client export must consume the same archive; publishing only the client can make
it incompatible with the running server.
```sh
# Test the client and deploy the matching server release first.
python3 script/publish-prism-mirrors.py WorldOfSosal.mrpack
```
The mirror publisher creates temporary build directories and sets each server
address and update URL automatically. The builder downloads a SHA-256-pinned bootstrap from the
packwiz project's release, or accepts it via `--bootstrap /path/to/file.jar`.
External mods retain their original URLs and SHA-512 checksums. Embedded mods and
configuration are hosted with the release. Both required and optional client mods
are included, matching the current server importer's optional-mod behavior.
Publishing uploads an immutable directory, checks it if it already exists, and
atomically switches `current`. Previous directories remain available for rollback.
Do not remove a release while clients may still be reading it. Hash checks cause
an overlapping update to fail safely rather than silently accept mixed contents;
retry the launch if a publication overlapped a download.
The files live under `/var/www/store/minecraft/world-of-sosal` on `hectic-lab`,
served by the existing `store.hectic-lab.com` nginx virtual host. No nginx reload
is needed for pack updates. Keep `current/pack.toml` as the stable client URL.
The index must be alongside pack.toml: putting a release prefix in `[index].file`
also prefixes client installation paths with that directory in packwiz-installer.
If Minecraft/NeoForge versions change, update and test both the server pin and
client pack. packwiz-installer 0.5.14 understands NeoForge components in Prism's
`mmc-pack.json`; a launcher restart/relaunch may be necessary after changing them.
## Verification on 2026-09-18
- Source archive SHA-256:
`f8c18acb9208e4592725632ae50dab4f9c308483b34fd43a6507c74fdbf8169f`.
- Public HTTPS installation into a clean Prism-format instance passed: all 141
client mods and all overrides match the original archive. A second launch
performed no downloads and preserved personal options.
- Direct probes of neuro public ports 25565, 25567, and 25568 timed out;
the configured relay now provides the public entry point.
- Live WoW server reached `Done` with all 135 server mod SHA-512 hashes
matching the same archive used for the Prism client.
- Public `store.hectic-lab.com:25568` status/ping succeeded (about 111 ms);
a login handshake reached the online authentication encryption request.
An authenticated Windows Prism session was subsequently verified on 2026-09-19 (see below).
- Server and tunnel are enabled at boot; relay and both NixOS configurations
are deployed. No failed systemd units remain on neuro.
- Loader package `neoforge-1.21.1-21.1.250` built successfully in Nix.
- Automatic updater add/remove/config-update and options-preservation behavior
tested with an actual packwiz-installer run against a controlled update fixture.
## WoW server and public entry point
The WoW map and WorldOfSosal mods share the `wowMineMap` server on neuro,
listening on 25567. There is no separate WorldOfSosal world/server on 25568.
The client pack and server both pin Minecraft 1.21.1 / NeoForge 21.1.250.
Map import runs before mod import, and both finish before Minecraft starts.
The public entry point is `store.hectic-lab.com:25568`:
```
Prism -> hectic-lab:25568 -> loopback:25577 -> SSH tunnel -> neuro:25567
```
`minecraft-wow-proxy.socket` and its socket-proxyd service run on hectic-lab.
`minecraft-wow-tunnel.service` on neuro establishes a reverse SSH forward and
reconnects after failures. A dedicated SSH identity may listen only on
127.0.0.1:25577 at the relay; it has no interactive shell or other forwarding.
Both services and firewall rules are in Nix and start on boot. The SSH client
uses an explicit AES-CTR / HMAC-SHA256-ETM / curve25519 transport profile with
IPQoS=none, tested on the neuro-to-lab route. The default profile stalled after
the handshake on this route. Both ends check peer liveness so stale listeners
are eventually released. Minecraft initially used `online-mode=true`. It now uses offline mode at the
owner's request; see the RCON and authentication section below.
For a temporary direct local tunnel, use:
```sh
ssh -NTL 0.0.0.0:25568:127.0.0.1:25567 \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 neuro
```
That command exposes the local 25568 listener on all interfaces, as requested.
Use 127.0.0.1 instead of the first 0.0.0.0 if only this computer should use it.
Credentials are encrypted in `sus/neuro-minecraft.yaml` with the actual neuro
host identity and owner keys. The existing `sus/neuro.yaml` is unchanged.
The source WoW archive remains untouched in Storage Box. Import is idempotent:
an existing world with level.dat is preserved. Never delete the world to update
mods; publish/deploy a matching modpack release instead.
Useful checks:
```sh
ssh neuro systemctl status minecraft-world-import-wowMineMap \
minecraft-modpack-import-worldOfSosal minecraft-server-wowMineMap \
minecraft-wow-tunnel --no-pager
ssh hectic-lab systemctl status minecraft-wow-proxy.socket --no-pager
ssh neuro journalctl -u minecraft-server-wowMineMap -n 80 --no-pager
```
The initial isolated server compatibility test reached `Done` and answered the
Minecraft status/ping protocol. Its logs also contain nonfatal recipe and class
function errors from the supplied modpack; successful startup does not imply that
every recipe or RPG class feature works correctly.
The imported map metadata is `wow mine`, DataVersion 3953 (Minecraft 1.21),
spawn 0 / 68 / -32; extracted size is approximately 11.7 GiB. The archive
SHA-256 was verified before extraction.
## Windows Prism GUI verification on 2026-09-19
- Downloaded the published ZIP through the browser and imported it in Prism 8.4.
- Fixed the generated instance.cfg: ConfigVersion=1.2 is required. Without it,
Prism selects its legacy INI parser and corrupts the quoted pre-launch command.
The corrected ZIP is published at the same URL. Previously imported copies
need the command corrected in Settings / Custom commands, or a fresh import.
- Used Java 21.0.4; the first packwiz download hit two transient timeouts.
Cancelled the incomplete launch and retried successfully. All 141 downloaded
client mod hashes match the original mrpack. NeoForge reports 202 mods when
bundled/internal mod components are included.
- Joined store.hectic-lab.com:25568 in the actual Minecraft GUI. The server
confirmed the authenticated join, and the client reached the Origins selection
screen. No character origin was selected during testing.
- Tested a separate copy of the pack manifest with an inert config text file:
launching from Prism added it; restoring the production manifest and launching
again automatically deleted it. Existing files were reused from cache, and
options.txt retained its checksum. The production pack contents were unchanged.
- Restored the instance's regular current/pack.toml update URL.
## Independent BFS entry point (2026-09-19)
- Server: `wow.bfs.band`; downloads: https://store.bfs.band/minecraft/.
- BFS is `bfs.poland.xray` (91.198.166.181), the host of Element.
- `minecraft-wow-tunnel-bfs` connects neuro directly to BFS. The BFS path does
not transit hectic-lab; both tunnels have independent reconnecting services.
- Shared proxy implementation: `nixos/module/generic/minecraft-public-relay.nix`.
Host settings remain in `minecraft-wow-proxy.nix` (hectic-lab) and
`minecraft-wow.nix` (BFS). A dedicated HTTPS virtual host serves `store.bfs.band`. The legacy
`bfs.band/minecraft/` URLs remain available for already imported instances.
- Downloaded BFS ZIP seeds `wow.bfs.band` and uses the stable manifest
`https://store.bfs.band/minecraft/world-of-sosal/current/pack.toml`. It does not
redirect installation metadata to hectic-lab. Upstream mod and Java/loader
downloads still use their original providers (e.g. Modrinth, GitHub, Mojang).
- Existing hectic-lab instances can be migrated without reinstalling mods:
in Edit / Settings / Custom commands, replace only the manifest URL in
Pre-launch command with the BFS URL above. Change the multiplayer server
address to wow.bfs.band. New users should import the ZIP from BFS.
- `script/publish-prism-mirrors.py` builds host-specific ZIPs from one archive
and publishes both mirrors. It checks that the running neuro server's cached
archive has the same SHA-256. Each host's switch is atomic; publication across
two hosts is sequential, so rerun the command if it exits unsuccessfully.
- Both configurations were deployed; public Minecraft status/ping succeeds
on BFS (~125 ms), HTTPS serves the pack, and Element/Matrix HTTP checks pass.
Clean installation through the BFS manifest passed: all 141 client mods and
all overrides match the source archive. A second updater run performed no
downloads and preserved options.txt. The public BFS login protocol reached
online authentication; the earlier full GUI login used hectic-lab.
## BFS DNS and dedicated download site (2026-09-19)
Porkbun DNS, TTL 600:
| Type | Name | Value |
| --- | --- | --- |
| A | store.bfs.band | 91.198.166.181 |
| A | wow.bfs.band | 91.198.166.181 |
| SRV | _minecraft._tcp.wow.bfs.band | 0 0 25568 wow.bfs.band |
Players enter `wow.bfs.band` without a port in Minecraft Java. In Porkbun,
SRV Priority is `0`, and Target is `0 25568 wow.bfs.band` (weight, port, host).
The root download URL https://store.bfs.band/ redirects to the WorldOfSosal page.
The NixOS virtual host obtains and renews its HTTPS certificate automatically.
The publication script now seeds this update URL and the port-free game address.
Existing BFS instances retain working legacy update URLs; switching their
pre-launch manifest to the new store host is optional. Root bfs.band remains
the existing Element entry point.
## RCON and authentication (2026-09-19)
The WoW server now has `online-mode=false`. Account authentication is disabled;
player names can be impersonated, and offline UUIDs differ from online UUIDs.
Existing inventory/permissions may require a separate UUID migration.
RCON listens on TCP 25575 on neuro; its port is not opened in the firewall or
forwarded through the public Minecraft relays. The server-specific automatic
firewall is disabled and only game port 25567 is explicitly permitted.
A random password is stored in SOPS as `minecraft/rcon-password`, injected into
server.properties at startup with mode 0600, and is absent from the Nix store.
Start a local-only SSH tunnel and leave it running:
```sh
ssh -NT -L 127.0.0.1:25575:127.0.0.1:25575 -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 neuro
```
Retrieve the password in another terminal (do not paste it into logs):
```sh
ssh neuro cat /run/secrets/minecraft/rcon-password
```
Configure the RCON client with host `127.0.0.1`, port `25575`, and that password.
There is no RCON username. These changes apply to wowMineMap only.
+119
View File
@@ -0,0 +1,119 @@
# Project Zomboid backups
`hectic.services."project-zomboid".backup` creates local backups without stopping
or pausing the server. The default schedule is every 30 minutes. Each run:
1. sends the local RCON `save` command and waits for the configured save grace
period;
2. rsyncs `Zomboid/Saves/Multiplayer/<serverName>` and non-secret server
settings (`SandboxVars`, spawn-points, and spawn-regions) from
`Zomboid/Server` into a private staging tree;
3. waits five seconds and repeats the rsync to narrow the live-write window;
4. publishes a timestamped `tar.zst` archive; and
5. uploads it to S3 when enabled, then applies local retention.
With `backup.retentionDays = 0`, local timestamped archives are removed after a
successful S3 upload (or after local creation when S3 is disabled). A failed
upload leaves the current archive locally and the next run retries that archive
before creating a new one. Restore archives named `pre-restore` are not managed
by this cleanup.
The service lock prevents overlapping runs. Missing save or server-config paths
skip the run through systemd `ConditionPathExists` checks.
## Consistency and secrets
This is a best-effort backup. It does not stop Project Zomboid and does not use
an atomic filesystem snapshot. The RCON save command flushes the world before
copying, and the second rsync narrows the remaining live-write window, but
neither makes the filesystem copy an atomic snapshot.
Archives do not include the generated server INI, `admin-password`,
host-generated password files, or the S3 credentials file. The server INI is
generated again during service startup; provision secret-backed values separately
after a restore.
## hectic-lab
hectic-lab runs the timer every 30 minutes and stores timestamped backups in
S3 for 14 days. It keeps no regular timestamped backup archives locally:
```text
/var/lib/project-zomboid/backups/archive/
```
The existing `project-zomboid-restore.sh` helper expects the fresh backup it
creates to remain in this directory for rollback. Therefore, with local
retention set to zero, do not use the helper until it is adapted for S3-only
retention; temporarily configure positive local retention for a restore.
Check it with:
```sh
systemctl list-timers project-zomboid-backup.timer
systemctl status project-zomboid-backup.service
journalctl -u project-zomboid-backup.service
```
RCON is enabled on localhost port `27015`; the firewall does not expose this
port. The password is generated at
`/var/lib/project-zomboid/rcon-password` with mode `0600`. The server also uses
`SaveWorldEveryMinutes=15` as a periodic persistence fallback.
## Optional S3 upload
S3 upload is disabled by default. Enabling it requires `bucket`, `endpoint`,
`region`, and an absolute runtime `credentialsFile` outside `/nix/store`. The
endpoint must use HTTPS. systemd reads the environment file without executing
it; this host keeps it owned by `project-zomboid` with mode `0400`:
```sh
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
```
Set `backup.s3.prefix` to choose the object-key prefix and
`backup.s3.remoteRetentionDays` to prune old archives from that prefix. Remote
deletion runs only after a successful upload and only matches this server's
archive name prefix. Configure bucket lifecycle expiration/versioning too when
available; it remains the stronger recovery and cleanup control.
## Restore
Restoring must be done while the server is stopped so it cannot modify files
during extraction:
On hectic-lab, regular timestamped archives are retained only in S3. With
valid S3 credentials, download the chosen archive before restoring it:
```sh
aws s3 cp \
s3://backup-hectic-lab/project-zomboid/project-zomboid-servertest-<timestamp>.tar.zst \
/var/lib/project-zomboid/backups/archive/<archive>.tar.zst \
--endpoint-url https://hel1.your-objectstorage.com \
--region hel1
```
The versioned helper creates a fresh current-state backup, stops the timer and
server, validates archive paths, restores the save, and starts both services:
```sh
sudo ./docs/project-zomboid-restore.sh \
/var/lib/project-zomboid/backups/archive/<archive>.tar.zst
```
It writes a rollback archive named
`project-zomboid-<serverName>-pre-restore-<timestamp>.tar.zst` before changing
the save.
```sh
systemctl stop project-zomboid.service
tar --zstd --no-same-owner --no-same-permissions \
-xf /var/lib/project-zomboid/backups/archive/<archive>.tar.zst \
-C /var/lib/project-zomboid
chown -R project-zomboid:project-zomboid /var/lib/project-zomboid/Zomboid
systemctl start project-zomboid.service
```
Re-provision password files and secret-backed INI values before starting.
Verify the restored save and server name before allowing players to reconnect.
+143
View File
@@ -0,0 +1,143 @@
#!/bin/sh
set -eu
SERVER_NAME=${SERVER_NAME:-servertest}
DATA_DIR=${DATA_DIR:-/var/lib/project-zomboid}
ARCHIVE=${1:-}
usage() {
printf '%s\n' "Usage: $0 /path/to/project-zomboid-${SERVER_NAME}-<timestamp>.tar.zst"
printf '%s\n' "Environment: SERVER_NAME, DATA_DIR"
}
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' 'Run as root.' >&2
exit 1
fi
if [ -z "$ARCHIVE" ]; then
usage >&2
exit 2
fi
if [ ! -r "$ARCHIVE" ]; then
printf 'Backup archive is not readable: %s\n' "$ARCHIVE" >&2
exit 1
fi
ARCHIVE_DIR="$DATA_DIR/backups/archive"
SAVE_DIR="$DATA_DIR/Zomboid/Saves/Multiplayer/$SERVER_NAME"
SERVER_DIR="$DATA_DIR/Zomboid/Server"
TMP_LIST=$(mktemp)
ROLLBACK_ARCHIVE=''
SERVER_STOPPED=0
RESTORE_SUCCEEDED=0
cleanup() {
rm -f "$TMP_LIST"
}
on_exit() {
status=$?
if [ "$status" -ne 0 ] && [ "$SERVER_STOPPED" -eq 1 ] \
&& [ "$RESTORE_SUCCEEDED" -eq 0 ] && [ -n "$ROLLBACK_ARCHIVE" ]; then
set +e
rm -rf "$SAVE_DIR"
rm -f \
"$SERVER_DIR/${SERVER_NAME}_SandboxVars.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnpoints.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnregions.lua"
tar --zstd --no-same-owner --no-same-permissions \
-xpf "$ROLLBACK_ARCHIVE" -C "$DATA_DIR"
chown -R project-zomboid:project-zomboid "$SAVE_DIR" "$SERVER_DIR"
systemctl start project-zomboid.service
systemctl start project-zomboid-backup.timer
printf '%s\n' "Restore failed; current state restored from $ROLLBACK_ARCHIVE" >&2
fi
cleanup
exit "$status"
}
trap on_exit EXIT
if ! tar --zstd -tf "$ARCHIVE" >"$TMP_LIST"; then
printf 'Archive integrity check failed: %s\n' "$ARCHIVE" >&2
exit 1
fi
while IFS= read -r member; do
case "$member" in
Zomboid/*) ;;
*)
printf 'Unsafe archive member: %s\n' "$member" >&2
exit 1
;;
esac
case "$member" in
/*|*../*)
printf 'Path traversal member: %s\n' "$member" >&2
exit 1
;;
esac
done <"$TMP_LIST"
if ! systemctl start project-zomboid-backup.service; then
printf '%s\n' 'Could not create fresh backup of current state.' >&2
exit 1
fi
CURRENT_ARCHIVE=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f \
-name "project-zomboid-$SERVER_NAME-*.tar.zst" \
-printf '%T@ %p\n' | sort -nr | awk 'NR == 1 {sub(/^[^ ]* /, ""); print}')
if [ -z "$CURRENT_ARCHIVE" ]; then
printf '%s\n' 'Fresh current-state backup was not found.' >&2
exit 1
fi
stamp=$(date -u +%Y%m%dT%H%M%SZ)
ROLLBACK_ARCHIVE="$ARCHIVE_DIR/project-zomboid-$SERVER_NAME-pre-restore-$stamp.tar.zst"
cp --reflink=auto "$CURRENT_ARCHIVE" "$ROLLBACK_ARCHIVE" 2>/dev/null \
|| cp "$CURRENT_ARCHIVE" "$ROLLBACK_ARCHIVE"
chmod 0600 "$ROLLBACK_ARCHIVE"
chown project-zomboid:project-zomboid "$ROLLBACK_ARCHIVE"
systemctl stop project-zomboid-backup.timer
systemctl stop project-zomboid.service
SERVER_STOPPED=1
if [ "$(systemctl show project-zomboid --property=ActiveState --value)" != inactive ]; then
printf '%s\n' 'Project Zomboid did not stop; refusing to restore.' >&2
exit 1
fi
rm -rf "$SAVE_DIR"
rm -f \
"$SERVER_DIR/${SERVER_NAME}_SandboxVars.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnpoints.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnregions.lua"
tar --zstd --no-same-owner --no-same-permissions \
-xpf "$ARCHIVE" -C "$DATA_DIR"
chown -R project-zomboid:project-zomboid "$SAVE_DIR" "$SERVER_DIR"
systemctl start project-zomboid.service
started=0
for _ in $(seq 1 90); do
if [ "$(systemctl show project-zomboid --property=ActiveState --value)" = active ] \
&& [ "$(systemctl show project-zomboid --property=SubState --value)" = running ]; then
started=1
break
fi
sleep 2
done
if [ "$started" -ne 1 ]; then
printf 'Restore completed, but service did not become healthy. Rollback: %s\n' \
"$ROLLBACK_ARCHIVE" >&2
exit 1
fi
systemctl start project-zomboid-backup.timer
RESTORE_SUCCEEDED=1
printf 'Restore completed.\n'
printf 'Rollback archive: %s\n' "$ROLLBACK_ARCHIVE"
Generated
+62 -1
View File
@@ -625,6 +625,28 @@
"type": "github"
}
},
"iana-angl": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1789498292,
"narHash": "sha256-bsrT7MWTXL+tpwDZmI5xWLPZZPYAzU1WjcGlNmqpePw=",
"ref": "refs/heads/master",
"rev": "2937d257d601b40de2437a51ebc3c7b61b40f679",
"revCount": 37,
"type": "git",
"url": "https://gitea.hectic-lab.com/yukkop/learning.git"
},
"original": {
"rev": "2937d257d601b40de2437a51ebc3c7b61b40f679",
"type": "git",
"url": "https://gitea.hectic-lab.com/yukkop/learning.git"
}
},
"impermanence": {
"locked": {
"lastModified": 1737831083,
@@ -893,6 +915,22 @@
"type": "github"
}
},
"nixpkgs-gitea": {
"locked": {
"lastModified": 1790323409,
"narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1779796641,
@@ -986,6 +1024,7 @@
"hectic-landing": "hectic-landing",
"home-manager": "home-manager",
"hyprland": "hyprland",
"iana-angl": "iana-angl",
"impermanence": "impermanence",
"mechabellum-replay-analysis": "mechabellum-replay-analysis",
"nix-darwin": "nix-darwin",
@@ -995,12 +1034,34 @@
"nixos-mailserver": "nixos-mailserver",
"nixos-wsl": "nixos-wsl",
"nixpkgs": "nixpkgs_2",
"nixpkgs-gitea": "nixpkgs-gitea",
"nixvim": "nixvim",
"rust-overlay": "rust-overlay",
"rust-overlay": "rust-overlay_2",
"sops-nix": "sops-nix"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"iana-angl",
"nixpkgs"
]
},
"locked": {
"lastModified": 1789457514,
"narHash": "sha256-Aggle++fTyAifBy+QBPxjM+obO5iepKW/8MDxQtgGvI=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "89e99bf0778a8f2cd18c9360c3f19c1ee47fc739",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"rust-overlay_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
+5
View File
@@ -13,6 +13,7 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
nixpkgs-gitea.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs = {
@@ -72,6 +73,10 @@
url = "git+ssh://git@github.com/liquizz/hectic-landing.git";
inputs.nixpkgs.follows = "nixpkgs";
};
iana-angl = {
url = "git+https://gitea.hectic-lab.com/yukkop/learning.git?rev=2937d257d601b40de2437a51ebc3c7b61b40f679";
inputs.nixpkgs.follows = "nixpkgs";
};
mechabellum-replay-analysis = {
# NOTE(yukkop): private repo - SSH access required.
# Only evaluated when nixosConfigurations."hectic-lab|x86_64-linux" is built.
+1 -1
View File
@@ -126,7 +126,7 @@ in {
else throw (envErrorMessage varName);
# -- Cargo.toml --
cargoToml = src: (builtins.fromTOML (builtins.readFile "${src}/Cargo.toml"));
cargoToml = manifest: (builtins.fromTOML (builtins.readFile manifest));
# Consolidated SQL bundles for the `hectic` schema. Single source of truth
# for everything that creates objects in the `hectic` namespace, used by
@@ -0,0 +1,56 @@
{ ... }:
{ config, lib, pkgs, ... }:
let
cfg = config.services.minecraft-public-relay;
in {
options.services.minecraft-public-relay = {
enable = lib.mkEnableOption "restricted SSH relay for Minecraft";
publicPort = lib.mkOption { type = lib.types.port; default = 25568; };
tunnelPort = lib.mkOption { type = lib.types.port; default = 25577; };
publicKey = lib.mkOption {
type = lib.types.str;
description = "Public SSH key of the Minecraft tunnel client";
};
};
config = lib.mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [ cfg.publicPort ];
users.groups.mc-wow-relay = { };
users.users.mc-wow-relay = {
isSystemUser = true;
group = "mc-wow-relay";
openssh.authorizedKeys.keys = [
"restrict,port-forwarding,permitlisten=\"127.0.0.1:${toString cfg.tunnelPort}\" ${cfg.publicKey}"
];
};
services.openssh.extraConfig = ''
Match User mc-wow-relay
ClientAliveInterval 15
ClientAliveCountMax 3
AllowTcpForwarding remote
PermitListen 127.0.0.1:${toString cfg.tunnelPort}
AllowAgentForwarding no
X11Forwarding no
PermitTTY no
ForceCommand ${pkgs.coreutils}/bin/false
Match all
'';
systemd.sockets.minecraft-wow-proxy = {
description = "WorldOfSosal WoW public Minecraft port";
wantedBy = [ "sockets.target" ];
listenStreams = [ "0.0.0.0:${toString cfg.publicPort}" ];
};
systemd.services.minecraft-wow-proxy = {
description = "Forward Minecraft to the neuro reverse tunnel";
requires = [ "minecraft-wow-proxy.socket" ];
after = [ "minecraft-wow-proxy.socket" ];
serviceConfig = {
ExecStart = "${pkgs.systemd}/lib/systemd/systemd-socket-proxyd 127.0.0.1:${toString cfg.tunnelPort}";
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
};
};
};
}
+7
View File
@@ -21,6 +21,13 @@
plugin_daemon:
ports: !override
- "127.0.0.1:${toString cfg.pluginPort}:5003"
environment:
DB_USERNAME: ''${DB_USERNAME:-postgres}
DB_HOST: ''${DB_HOST:-db_postgres}
DB_PORT: ''${DB_PORT:-5432}
REDIS_HOST: ''${REDIS_HOST:-redis}
REDIS_PORT: ''${REDIS_PORT:-6379}
REDIS_DB: ''${REDIS_DB:-0}
'';
in {
options.hectic.services.dify = {
+10 -10
View File
@@ -59,25 +59,25 @@ in
host = lib.mkOption {
type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9.-]*";
default = "u666713.your-storagebox.de";
default = "u666713-sub1.your-storagebox.de";
description = "Hetzner Storage Box SMB hostname.";
};
username = lib.mkOption {
type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_-]*";
default = "u666713";
default = "u666713-sub1";
description = "Storage Box SMB username.";
};
share = lib.mkOption {
type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_-]*";
default = "backup";
default = "u666713-sub1";
description = "SMB share exported by Storage Box.";
};
subdirectory = lib.mkOption {
type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_./-]*";
default = "immich";
type = lib.types.nullOr (lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9_./-]*");
default = null;
description = "Directory within the SMB share used by Immich.";
};
@@ -146,8 +146,7 @@ in
options = [
"_netdev"
"nofail"
"x-systemd.automount"
"x-systemd.idle-timeout=600"
"x-systemd.mount-timeout=60s"
"vers=3.1.1"
"seal"
"cache=none"
@@ -157,12 +156,13 @@ in
"gid=${config.services.immich.group}"
"file_mode=0660"
"dir_mode=0770"
"prefixpath=${cfg.storageBox.subdirectory}"
];
] ++ lib.optional (cfg.storageBox.subdirectory != null)
"prefixpath=${cfg.storageBox.subdirectory}";
};
systemd.services.immich-server.serviceConfig.RequiresMountsFor = lib.mkIf cfg.storageBox.enable [
systemd.services.immich-server.unitConfig.RequiresMountsFor = lib.mkIf cfg.storageBox.enable [
cfg.mediaLocation
];
systemd.services.immich-server.serviceConfig.Restart = lib.mkForce "always";
};
}
+2 -2
View File
@@ -56,7 +56,7 @@
"-L" "${cfg.dataDir}/logs/bootstrap.log"
] ++ lib.optional (!cfg.caseSensitive) "-C1";
initScript = pkgs.writeShellScript "p4d-init" ''
initScript = ''
set -eu
export P4ROOT=${lib.escapeShellArg cfg.dataDir}
@@ -334,7 +334,7 @@ in {
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
preStart = builtins.readFile initScript;
preStart = initScript;
serviceConfig = {
Type = "simple";
User = serviceUser;
+441 -3
View File
@@ -16,7 +16,230 @@
name: value:
"${name}=${if builtins.isBool value then lib.boolToString value else toString value}"
) serverProperties;
sandboxValueType = lib.types.oneOf [
lib.types.bool
lib.types.int
lib.types.float
lib.types.str
(lib.types.attrsOf sandboxValueType)
];
luaValue = value:
if builtins.isBool value then
lib.boolToString value
else if builtins.isInt value || builtins.isFloat value then
toString value
else if builtins.isAttrs value then
"{ ${lib.concatStringsSep " " (lib.mapAttrsToList (name: child: "[${luaValue name}] = ${luaValue child},") value)} }"
else
"\"${lib.replaceStrings [ "\\" "\"" "\n" "\r" ] [ "\\\\" "\\\"" "\\n" "\\r" ] value}\"";
sandboxConfigLines = lib.mapAttrsToList (
name: value: "[${luaValue name}] = ${luaValue value},"
) cfg.sandboxProperties;
zomboidDir = "${cfg.dataDir}/Zomboid";
adminPasswordFile = "${cfg.dataDir}/admin-password";
rconPasswordFile = cfg.rcon.passwordFile;
backupCfg = cfg.backup;
s3CredentialsFile = if backupCfg.s3.credentialsFile == null then "" else backupCfg.s3.credentialsFile;
s3Bucket = if backupCfg.s3.bucket == null then "" else backupCfg.s3.bucket;
s3Endpoint = if backupCfg.s3.endpoint == null then "" else backupCfg.s3.endpoint;
s3Region = if backupCfg.s3.region == null then "" else backupCfg.s3.region;
timestampArchivePattern = "project-zomboid-${cfg.serverName}-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z.tar.zst";
saveDir = "${zomboidDir}/Saves/Multiplayer/${cfg.serverName}";
serverConfigDir = "${zomboidDir}/Server";
backupScript = pkgs.writeShellScript "project-zomboid-backup" ''
set -eu
staging_dir=${lib.escapeShellArg backupCfg.stagingDir}
archive_dir=${lib.escapeShellArg backupCfg.archiveDir}
lock_file="$archive_dir/.backup.lock"
${pkgs.coreutils}/bin/install -d -m 0700 \
"$staging_dir/Zomboid/Saves/Multiplayer/${cfg.serverName}" \
"$staging_dir/Zomboid/Server" \
"$archive_dir"
exec 9>"$lock_file"
if ! ${pkgs.util-linux}/bin/flock -n 9; then
${pkgs.coreutils}/bin/printf '%s\n' 'Project Zomboid backup already running; skipping.' >&2
exit 0
fi
${lib.optionalString backupCfg.s3.enable ''
if [ -z "''${AWS_ACCESS_KEY_ID:-}" ] || [ -z "''${AWS_SECRET_ACCESS_KEY:-}" ]; then
${pkgs.coreutils}/bin/printf '%s\n' \
'AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY missing from Project Zomboid S3 credentials file.' >&2
exit 1
fi
s3_bucket=${lib.escapeShellArg s3Bucket}
s3_prefix=${lib.escapeShellArg backupCfg.s3.prefix}
s3_upload() {
source=$1
key=$2
attempt=1
while [ "$attempt" -le 3 ]; do
if ${pkgs.awscli2}/bin/aws s3 cp "$source" "s3://$s3_bucket/$key" \
--endpoint-url ${lib.escapeShellArg s3Endpoint} \
--region ${lib.escapeShellArg s3Region} \
--cli-connect-timeout 30 \
--cli-read-timeout 300 \
--only-show-errors; then
return 0
fi
if [ "$attempt" -eq 3 ]; then
return 1
fi
${pkgs.coreutils}/bin/sleep 5
attempt=$((attempt + 1))
done
}
''}
${lib.optionalString (backupCfg.s3.enable && backupCfg.retentionDays == 0) ''
pending_list="$staging_dir/.pending-archives"
${pkgs.findutils}/bin/find "$archive_dir" -maxdepth 1 -type f \
-name ${lib.escapeShellArg timestampArchivePattern} \
-printf '%T@ %p\n' | ${pkgs.coreutils}/bin/sort -n > "$pending_list"
while IFS= read -r pending_line; do
pending_archive="''${pending_line#* }"
pending_name="''${pending_archive##*/}"
s3_upload "$pending_archive" "''${s3_prefix:+$s3_prefix/}$pending_name"
${pkgs.coreutils}/bin/rm -f "$pending_archive"
done < "$pending_list"
${pkgs.coreutils}/bin/rm -f "$pending_list"
''}
${lib.optionalString cfg.rcon.enable ''
rcon_password="$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg rconPasswordFile})"
if [ -z "$rcon_password" ]; then
${pkgs.coreutils}/bin/printf '%s\n' 'Project Zomboid RCON password file is empty.' >&2
exit 1
fi
${pkgs.rcon}/bin/rcon \
--host 127.0.0.1 \
--port ${toString cfg.rcon.port} \
--password "$rcon_password" \
save
${pkgs.coreutils}/bin/sleep ${toString backupCfg.saveWaitSeconds}
''}
sync_staging() {
${pkgs.rsync}/bin/rsync -a --delete \
${lib.escapeShellArg "${saveDir}/"} \
"$staging_dir/Zomboid/Saves/Multiplayer/${cfg.serverName}/"
${pkgs.rsync}/bin/rsync -a --delete --delete-excluded \
--include=${lib.escapeShellArg "/${cfg.serverName}_SandboxVars.lua"} \
--include=${lib.escapeShellArg "/${cfg.serverName}_spawnpoints.lua"} \
--include=${lib.escapeShellArg "/${cfg.serverName}_spawnregions.lua"} \
--exclude='*' \
${lib.escapeShellArg "${serverConfigDir}/"} \
"$staging_dir/Zomboid/Server/"
}
# Second pass narrows, but cannot eliminate, live-save inconsistency.
sync_staging
${pkgs.coreutils}/bin/sleep 5
sync_staging
timestamp="$(${pkgs.coreutils}/bin/date -u +%Y%m%dT%H%M%SZ)"
archive_name="project-zomboid-${cfg.serverName}-$timestamp.tar.zst"
archive_tmp="$archive_dir/.$archive_name.tmp"
archive="$archive_dir/$archive_name"
trap '${pkgs.coreutils}/bin/rm -f "$archive_tmp"' EXIT
${pkgs.gnutar}/bin/tar \
--use-compress-program=${lib.escapeShellArg "${pkgs.zstd}/bin/zstd -T0"} \
-C "$staging_dir" -cf "$archive_tmp" Zomboid
${pkgs.coreutils}/bin/chmod 0600 "$archive_tmp"
${pkgs.coreutils}/bin/mv "$archive_tmp" "$archive"
trap - EXIT
${lib.optionalString backupCfg.s3.enable ''
s3_key="''${s3_prefix:+$s3_prefix/}$archive_name"
s3_upload "$archive" "$s3_key"
${lib.optionalString (backupCfg.retentionDays == 0) ''
${pkgs.coreutils}/bin/rm -f "$archive"
''}
${lib.optionalString (backupCfg.retentionDays != 0) ''
${pkgs.findutils}/bin/find "$archive_dir" -maxdepth 1 -type f \
-name ${lib.escapeShellArg timestampArchivePattern} \
-mmin +${toString (backupCfg.retentionDays * 1440)} -delete
''}
remote_prefix="$s3_prefix"
if [ -n "$remote_prefix" ]; then
remote_prefix="$remote_prefix/"
fi
archive_prefix=${lib.escapeShellArg "project-zomboid-${cfg.serverName}-"}
remote_list="$staging_dir/.remote-objects.json"
remote_delete_dir="$staging_dir/.remote-delete"
${pkgs.awscli2}/bin/aws s3api list-objects-v2 \
--bucket "$s3_bucket" \
--prefix "$remote_prefix" \
--endpoint-url ${lib.escapeShellArg s3Endpoint} \
--region ${lib.escapeShellArg s3Region} \
--output json > "$remote_list"
${pkgs.python3}/bin/python3 - "$remote_list" "$remote_delete_dir" \
"$(( $(${pkgs.coreutils}/bin/date +%s) - ${toString (backupCfg.s3.remoteRetentionDays * 86400)} ))" \
"$remote_prefix$archive_prefix" <<'PY'
import datetime
import json
import os
import re
import sys
objects_path, delete_dir, cutoff, key_prefix = sys.argv[1:]
cutoff = int(cutoff)
archive_pattern = re.compile(
re.escape(key_prefix) + r"\d{8}T\d{6}Z\.tar\.zst\Z"
)
with open(objects_path, encoding="utf-8") as stream:
objects = json.load(stream).get("Contents", [])
old_keys = []
for item in objects:
key = item.get("Key", "")
if not archive_pattern.fullmatch(key):
continue
modified = datetime.datetime.fromisoformat(
item["LastModified"].replace("Z", "+00:00")
)
if int(modified.timestamp()) < cutoff:
old_keys.append(key)
os.makedirs(delete_dir, exist_ok=True)
for batch_number in range(0, len(old_keys), 1000):
batch = old_keys[batch_number:batch_number + 1000]
manifest_path = os.path.join(
delete_dir, f"batch-{batch_number // 1000:04d}.json"
)
with open(manifest_path, "w", encoding="utf-8") as stream:
json.dump(
{"Objects": [{"Key": key} for key in batch], "Quiet": True},
stream,
)
PY
for remote_manifest in "$remote_delete_dir"/*.json; do
[ -f "$remote_manifest" ] || continue
${pkgs.awscli2}/bin/aws s3api delete-objects \
--bucket "$s3_bucket" \
--delete "file://$remote_manifest" \
--endpoint-url ${lib.escapeShellArg s3Endpoint} \
--region ${lib.escapeShellArg s3Region} \
--only-show-errors
done
${pkgs.coreutils}/bin/rm -rf "$remote_list" "$remote_delete_dir"
''}
${lib.optionalString (!backupCfg.s3.enable) ''
if [ ${toString (if backupCfg.retentionDays == 0 then 0 else 1)} -eq 0 ]; then
${pkgs.findutils}/bin/find "$archive_dir" -maxdepth 1 -type f \
-name ${lib.escapeShellArg timestampArchivePattern} \
-delete
else
${pkgs.findutils}/bin/find "$archive_dir" -maxdepth 1 -type f \
-name ${lib.escapeShellArg timestampArchivePattern} \
-mmin +${toString (backupCfg.retentionDays * 1440)} -delete
fi
''}
'';
startScript = pkgs.writeShellScript "project-zomboid-start" ''
admin_password=$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg adminPasswordFile})
exec ${pkgs.steam-run}/bin/steam-run \
@@ -100,14 +323,160 @@ in {
description = "Runtime file with additional INI values, suitable for secrets.";
};
sandboxProperties = lib.mkOption {
type = lib.types.attrsOf sandboxValueType;
default = { };
description = "Values for the Project Zomboid SandboxVars.lua file.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Open the Project Zomboid UDP ports in the firewall.";
};
rcon = {
enable = lib.mkEnableOption "local RCON for Project Zomboid automation";
port = lib.mkOption {
type = lib.types.port;
default = 27015;
description = "RCON TCP port; not opened in the firewall by this module.";
};
passwordFile = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/rcon-password";
description = "Runtime file containing the generated RCON password.";
};
};
backup = {
enable = lib.mkEnableOption "no-stop Project Zomboid backups";
onCalendar = lib.mkOption {
type = lib.types.str;
default = "*:0/30";
description = "systemd calendar expression controlling backup frequency.";
};
stagingDir = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/backups/staging";
description = "Local directory containing the two-pass rsync staging tree.";
};
archiveDir = lib.mkOption {
type = lib.types.path;
default = "${cfg.dataDir}/backups/archive";
description = "Local directory containing timestamped tar.zst archives.";
};
retentionDays = lib.mkOption {
type = lib.types.ints.between 0 3650;
default = 14;
description = "Delete local archives older than this many days; zero keeps no local archives.";
};
saveWaitSeconds = lib.mkOption {
type = lib.types.ints.positive;
default = 10;
description = "Seconds to wait after the RCON save command before rsync.";
};
s3 = {
enable = lib.mkEnableOption "uploading Project Zomboid backups to S3-compatible storage";
credentialsFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Runtime env file containing AWS_ACCESS_KEY_ID and
AWS_SECRET_ACCESS_KEY. Required when S3 upload is enabled.
'';
};
bucket = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "S3 bucket receiving backup archives.";
};
endpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "S3-compatible endpoint URL.";
};
region = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "S3 region passed to awscli2.";
};
prefix = lib.mkOption {
type = lib.types.str;
default = "project-zomboid";
description = "Optional object key prefix within the S3 bucket.";
};
remoteRetentionDays = lib.mkOption {
type = lib.types.ints.positive;
default = 14;
description = "Delete uploaded archives older than this many days.";
};
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !cfg.rcon.enable || (
lib.hasPrefix "/" cfg.rcon.passwordFile
&& !lib.hasPrefix "/nix/store/" cfg.rcon.passwordFile
);
message = "hectic.services.project-zomboid.rcon.passwordFile must be a runtime path outside /nix/store.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.enable;
message = "hectic.services.project-zomboid.backup must be enabled before S3 upload.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.s3.credentialsFile != null;
message = "hectic.services.project-zomboid.backup.s3.credentialsFile is required when S3 upload is enabled.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.s3.bucket != null;
message = "hectic.services.project-zomboid.backup.s3.bucket is required when S3 upload is enabled.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.s3.endpoint != null;
message = "hectic.services.project-zomboid.backup.s3.endpoint is required when S3 upload is enabled.";
}
{
assertion = !backupCfg.s3.enable || backupCfg.s3.region != null;
message = "hectic.services.project-zomboid.backup.s3.region is required when S3 upload is enabled.";
}
{
assertion =
!backupCfg.s3.enable
|| backupCfg.s3.credentialsFile == null
|| (
lib.hasPrefix "/" backupCfg.s3.credentialsFile
&& !lib.hasPrefix "/nix/store/" backupCfg.s3.credentialsFile
);
message = "hectic.services.project-zomboid.backup.s3.credentialsFile must be a runtime path outside /nix/store.";
}
{
assertion =
!backupCfg.s3.enable
|| backupCfg.s3.endpoint == null
|| lib.hasPrefix "https://" backupCfg.s3.endpoint;
message = "hectic.services.project-zomboid.backup.s3.endpoint must use HTTPS.";
}
];
users.groups.project-zomboid = { };
users.users.project-zomboid = {
isSystemUser = true;
@@ -119,6 +488,11 @@ in {
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 project-zomboid project-zomboid - -"
"d ${cfg.installDir} 0750 project-zomboid project-zomboid - -"
] ++ lib.optionals backupCfg.enable [
"d ${cfg.dataDir}/backups 0700 project-zomboid project-zomboid - -"
"Z ${cfg.dataDir}/backups 0700 project-zomboid project-zomboid - -"
"d ${backupCfg.stagingDir} 0700 project-zomboid project-zomboid - -"
"d ${backupCfg.archiveDir} 0700 project-zomboid project-zomboid - -"
];
systemd.services.project-zomboid = {
@@ -134,6 +508,22 @@ in {
umask 077
${pkgs.openssl}/bin/openssl rand -base64 32 > ${lib.escapeShellArg adminPasswordFile}
fi
${lib.optionalString cfg.rcon.enable ''
if [ ! -s ${lib.escapeShellArg rconPasswordFile} ]; then
umask 077
${pkgs.openssl}/bin/openssl rand -hex 32 > ${lib.escapeShellArg rconPasswordFile}
else
rcon_password=$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg rconPasswordFile})
case "$rcon_password" in
*[!0123456789abcdefABCDEF]*)
umask 077
${pkgs.openssl}/bin/openssl rand -hex 32 > ${lib.escapeShellArg rconPasswordFile}
;;
esac
fi
${pkgs.coreutils}/bin/chown project-zomboid:project-zomboid ${lib.escapeShellArg rconPasswordFile}
${pkgs.coreutils}/bin/chmod 0600 ${lib.escapeShellArg rconPasswordFile}
''}
${pkgs.steamcmd}/bin/steamcmd \
+force_install_dir ${lib.escapeShellArg cfg.installDir} \
+login anonymous \
@@ -143,14 +533,33 @@ in {
's/"-Xmx[0-9]+[mMgG]"/"-Xmx${cfg.memory}"/' \
${lib.escapeShellArg "${cfg.installDir}/ProjectZomboid64.json"}
${pkgs.coreutils}/bin/install -d -m 0750 \
${lib.escapeShellArg "${cfg.dataDir}/Server"}
${lib.escapeShellArg "${zomboidDir}/Server"}
{
${lib.concatMapStringsSep "\n " (line:
"${pkgs.coreutils}/bin/printf '%s\\n' ${lib.escapeShellArg line};"
) configLines}
${lib.optionalString (cfg.serverPropertiesFile != null)
"${pkgs.coreutils}/bin/cat ${lib.escapeShellArg cfg.serverPropertiesFile};"}
} > ${lib.escapeShellArg "${cfg.dataDir}/Server/${cfg.serverName}.ini"}
${lib.optionalString cfg.rcon.enable ''
${pkgs.coreutils}/bin/printf '%s\n' ${lib.escapeShellArg "RCONPort=${toString cfg.rcon.port}"};
${pkgs.coreutils}/bin/printf '%s' 'RCONPassword=';
${pkgs.coreutils}/bin/cat ${lib.escapeShellArg rconPasswordFile};
${pkgs.coreutils}/bin/printf '\n';
''}
} > ${lib.escapeShellArg "${zomboidDir}/Server/${cfg.serverName}.ini"}
${lib.optionalString (cfg.sandboxProperties != { }) ''
{
${pkgs.coreutils}/bin/printf '%s\n' 'SandboxVars = {';
${lib.concatMapStringsSep "\n " (line:
"${pkgs.coreutils}/bin/printf '%s\\n' ${lib.escapeShellArg line};"
) sandboxConfigLines}
${pkgs.coreutils}/bin/printf '%s\n' '};';
} > ${lib.escapeShellArg "${zomboidDir}/Server/${cfg.serverName}_SandboxVars.lua"}
''}
${lib.optionalString (cfg.sandboxProperties == { }) ''
${pkgs.coreutils}/bin/rm -f \
${lib.escapeShellArg "${zomboidDir}/Server/${cfg.serverName}_SandboxVars.lua"}
''}
'';
serviceConfig = {
@@ -159,16 +568,45 @@ in {
WorkingDirectory = cfg.dataDir;
Environment = [
"HOME=${cfg.dataDir}"
"SteamAppId=380870"
"SteamAppId=108600"
];
ExecStart = startScript;
Restart = "on-failure";
RestartSec = 5;
TimeoutStartSec = "15min";
TimeoutStopSec = 30;
UMask = "0077";
};
};
systemd.services.project-zomboid-backup = lib.mkIf backupCfg.enable {
description = "No-stop Project Zomboid backup";
after = [ "project-zomboid.service" ];
unitConfig.ConditionPathExists = [
saveDir
serverConfigDir
];
serviceConfig = {
Type = "oneshot";
User = "project-zomboid";
Group = "project-zomboid";
ExecStart = backupScript;
TimeoutStartSec = "30min";
UMask = "0077";
} // lib.optionalAttrs backupCfg.s3.enable {
EnvironmentFile = s3CredentialsFile;
};
};
systemd.timers.project-zomboid-backup = lib.mkIf backupCfg.enable {
description = "Run Project Zomboid backup";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = backupCfg.onCalendar;
Persistent = true;
};
};
networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall [
cfg.port
cfg.udpPort
@@ -16,6 +16,7 @@
matrixClusterSopsFile = flake + "/sus/matrix-cluster.yaml";
in {
imports = [
./minecraft-wow.nix
self.nixosModules.xray-system
self.nixosModules.matrix-cluster
self.nixosModules.matrix-cluster-users
@@ -0,0 +1,35 @@
{ ... }:
{
imports = [ (import ../../module/generic/minecraft-public-relay.nix { }) ];
services.minecraft-public-relay = {
enable = true;
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKNWWegOVTOF3EOmam32iP7sMybULMTxsXuC+cEGITQ8 minecraft-wow-relay";
};
systemd.tmpfiles.rules = [ "d /var/www/store/minecraft/world-of-sosal 0755 root root -" ];
services.nginx.virtualHosts."store.bfs.band" = {
enableACME = true;
forceSSL = true;
root = "/var/www/store";
locations."= /".return = "302 /minecraft/world-of-sosal/";
locations."= /minecraft".return = "302 /minecraft/world-of-sosal/";
locations."= /minecraft/".return = "302 /minecraft/world-of-sosal/";
locations."/".extraConfig = ''
autoindex off;
add_header Cache-Control "no-cache";
try_files $uri $uri/ =404;
'';
};
# Keep old pack URLs working for already imported Prism instances.
services.nginx.virtualHosts."bfs.band".locations = {
"= /minecraft".return = "302 /minecraft/world-of-sosal/";
"= /minecraft/".return = "302 /minecraft/world-of-sosal/";
"^~ /minecraft/" = {
root = "/var/www/store";
extraConfig = ''
autoindex off;
add_header Cache-Control "no-cache";
try_files $uri $uri/ =404;
'';
};
};
}
@@ -17,7 +17,7 @@ let
UsePAM yes
AuthenticationMethods publickey
AuthorizedKeysFile %h/.ssh/authorized_keys /etc/ssh/authorized_keys.d/%u
LogLevel DEBUG3
LogLevel INFO
VersionAddendum none
HostKeyAlgorithms rsa-sha2-512,rsa-sha2-256,ssh-ed25519
@@ -65,7 +65,8 @@ in
Type = "simple";
StateDirectory = "experimental-sshd";
RuntimeDirectory = "experimental-sshd";
ExecStart = "${pkgs.openssh}/bin/sshd -D -e -f /etc/ssh/experimental-sshd_config";
ExecStart = "${pkgs.openssh}/bin/sshd -D -f /etc/ssh/experimental-sshd_config";
StandardError = "journal";
};
preStart = ''
${pkgs.openssh}/bin/sshd -t -f /etc/ssh/experimental-sshd_config
+206 -9
View File
@@ -14,7 +14,7 @@ with builtins;
with lib;
let
domain = "hectic-lab.com";
sshPort = 22;
giteaSshPort = 22223;
mailUserNames = [
"security"
"founders"
@@ -48,6 +48,7 @@ let
giteaRunnerService = "gitea-runner-${giteaRunnerEscapedInstance}";
giteaRunnerTokenEnvService = "${giteaRunnerService}-token-env";
giteaRunnerTokenEnv = "/run/gitea-runner-${giteaRunnerInstance}/token.env";
worldOfSosalRoot = "/var/www/store/world-of-sosal";
in {
imports = [
self.nixosModules.hectic
@@ -58,11 +59,14 @@ in {
self.nixosModules."shadowsocks" # NOTE(nrv): usage/instance
inputs.hectic-landing.nixosModules.hectic-landing
inputs.iana-angl.nixosModules.iana-angl
(import ./attic.nix { inherit flake self inputs domain; })
(import ./containers.nix { inherit flake self inputs; })
./experimental-sshd.nix
./minecraft-wow-proxy.nix
(import ./ente.nix { inherit domain; })
(import ./immich.nix { inherit domain; })
(import ./mechabellum.nix { inherit flake self inputs domain; })
(import (./. + "/sentinèlla.nix") { inherit flake self inputs domain; })
];
@@ -75,6 +79,12 @@ in {
host = "127.0.0.1";
};
services.iana-angl = {
enable = true;
package = inputs.iana-angl.packages.${pkgs.stdenv.hostPlatform.system}.iana-angl;
domain = "lessons.${domain}";
};
# NOTE(yukkop): both nixos-mailserver and hectic-landing module set
# security.acme.defaults.email. Force the mailserver-aligned address.
security.acme.defaults.email = lib.mkForce "security@${domain}";
@@ -93,11 +103,81 @@ in {
};
services."project-zomboid" = {
enable = true;
memory = "3g";
memory = "4g";
serverName = "servertest";
workshopItems = [ ];
mods = [ ];
serverPropertiesFile = /var/lib/project-zomboid/server-password.ini;
rcon.enable = true;
backup = {
enable = true;
onCalendar = "*:0/30";
retentionDays = 0;
s3 = {
enable = true;
bucket = "backup-hectic-lab";
endpoint = "https://hel1.your-objectstorage.com";
region = "hel1";
credentialsFile = "/var/lib/project-zomboid/s3-credentials";
};
};
serverProperties = {
Map = "Muldraugh, KY";
SaveWorldEveryMinutes = 15;
DoLuaChecksum = false;
Public = true;
AntiCheatSafety = 4;
AntiCheatMovement = 4;
AntiCheatSpeed = 4;
AntiCheatHit = 4;
AntiCheatPacket = 4;
AntiCheatPacketException = 4;
AntiCheatPermission = 4;
AntiCheatXP = 4;
AntiCheatFire = 4;
AntiCheatSafeHouse = 4;
AntiCheatRecipe = 4;
AntiCheatPlayer = 4;
AntiCheatChecksum = 4;
AntiCheatItem = 4;
AntiCheatNoClip = 4;
AntiCheatServerCustomization = 4;
};
workshopItems = [
"3676456221" # Lua Digital Watch Framework
"3600401184" # Realistic Temperature Mod
];
mods = [
"\\LuaDigitalWatchUI"
"\\RC_RealisticColdMod"
];
sandboxProperties = {
StartMonth = 12;
StartDay = 1;
WaterShut = 3;
WaterShutModifier = 150;
ElecShut = 3;
ElecShutModifier = 150;
MinutesPerPage = 0.5;
Zombies = 4;
ZombieConfig = {
PopulationMultiplier = 1.3;
PopulationStartMultiplier = 1.0;
PopulationPeakMultiplier = 1.0;
RespawnHours = 0.0;
RespawnUnseenHours = 0.0;
RespawnMultiplier = 0.0;
RedistributeHours = 0.0;
};
ZombieLore = {
Transmission = 4;
Mortality = 7;
Speed = 2;
SprinterPercentage = 0;
Strength = 2;
Cognition = 2;
DoorOpeningPercentage = 10;
};
};
};
services.p4d = {
enable = true;
package = pkgs.p4d;
@@ -109,6 +189,7 @@ in {
# NOTE(yukkop): ephemeral Hetzner VM runners (1 VM = 1 job).
# Runbook: infra/gitea-runners/runbook.md "Ephemeral VM runner cutover".
enable = true;
budgetEurMonthly = "30";
imageId = "429747473"; # MicroOS x86 + persistent controller SSH key and writable Nix mount
armImageId = "423979717"; # OpenSUSE MicroOS ARM K3S 2026-08-24 snapshot
nixImageId = "161547269"; # Ubuntu 24.04 x86; Nix needs writable root
@@ -174,7 +255,8 @@ in {
key = "init-postgresql";
};
"atticd/environment" = {};
"wg-bfs/private-key" = {};
"immich/storage-box" = {};
"wg-bfs/private-key" = {};
"gitea-runner/org-registration-token" = {
sopsFile = flake + "/sus/gitea-runners.yaml";
key = "gitea/hectic-lab/org-runner-registration-token";
@@ -185,9 +267,50 @@ in {
"jwt-secret"
"s3-access-key"
"s3-secret-key"
]);
]) // {
"project-zomboid/s3-access-key" = {
key = "ente/s3-access-key";
owner = "project-zomboid";
group = "project-zomboid";
};
"project-zomboid/s3-secret-key" = {
key = "ente/s3-secret-key";
owner = "project-zomboid";
group = "project-zomboid";
};
};
};
systemd.services.project-zomboid.preStart = lib.mkBefore ''
password_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password"}
properties_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password.ini"}
s3_credentials_file=${lib.escapeShellArg "/var/lib/project-zomboid/s3-credentials"}
s3_credentials_tmp="$(${pkgs.coreutils}/bin/mktemp "''${s3_credentials_file}.XXXXXX")"
trap '${pkgs.coreutils}/bin/rm -f "$s3_credentials_tmp"' EXIT
{
${pkgs.coreutils}/bin/printf 'AWS_ACCESS_KEY_ID='
${pkgs.coreutils}/bin/cat ${lib.escapeShellArg config.sops.secrets."project-zomboid/s3-access-key".path}
${pkgs.coreutils}/bin/printf '\n'
${pkgs.coreutils}/bin/printf 'AWS_SECRET_ACCESS_KEY='
${pkgs.coreutils}/bin/cat ${lib.escapeShellArg config.sops.secrets."project-zomboid/s3-secret-key".path}
${pkgs.coreutils}/bin/printf '\n'
} > "$s3_credentials_tmp"
${pkgs.coreutils}/bin/chmod 0400 "$s3_credentials_tmp"
${pkgs.coreutils}/bin/mv -f "$s3_credentials_tmp" "$s3_credentials_file"
if [ ! -s "$password_file" ] || ! ${pkgs.gnugrep}/bin/grep -Eq '^[0-9a-f]{48}$' "$password_file"; then
umask 077
${pkgs.openssl}/bin/openssl rand -hex 24 > "$password_file"
fi
${pkgs.coreutils}/bin/chmod 0600 "$password_file"
properties_file_tmp="$(${pkgs.coreutils}/bin/mktemp "$(dirname "$properties_file")/.server-password.ini.XXXXXX")"
${pkgs.coreutils}/bin/printf 'Password=%s\n' "$(<"$password_file")" > "$properties_file_tmp"
${pkgs.coreutils}/bin/chmod 0600 "$properties_file_tmp"
${pkgs.coreutils}/bin/mv "$properties_file_tmp" "$properties_file"
'';
users.users.root.openssh.authorizedKeys.keys = [
# neuro machine
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDfqSROY+rp7amPPiArY3sZM7jTjYBS02csWxF/NeIr/ root@neuro"
@@ -219,7 +342,7 @@ in {
];
};
services.openssh.ports = [ sshPort ];
services.openssh.ports = [ giteaSshPort ];
services.mailserver = {
enable = true;
@@ -241,7 +364,7 @@ in {
networking.firewall = {
allowedTCPPorts = [
sshPort # ssh
giteaSshPort # gitea ssh
80
443
3306 # mysql
@@ -262,6 +385,8 @@ in {
systemd.tmpfiles.rules = [
"d /var/www/store 0755 nginx nginx -"
"d ${worldOfSosalRoot} 0750 root nginx -"
"d ${worldOfSosalRoot}/releases 0750 root nginx -"
];
systemd.services.${giteaRunnerTokenEnvService} = {
@@ -306,6 +431,69 @@ in {
autoindex on;
'';
};
locations."= /world-of-sosal/" = {
extraConfig = ''
return 302 /world-of-sosal/index.html;
'';
};
locations."= /world-of-sosal/index.html" = {
extraConfig = ''
alias ${./static/world-of-sosal/index.html};
default_type text/html;
add_header Cache-Control "no-cache" always;
limit_except GET {
deny all;
}
'';
};
locations."= /world-of-sosal/latest.mrpack" = {
extraConfig = ''
root /var/www/store;
default_type application/zip;
add_header Content-Disposition "attachment" always;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
try_files $uri =404;
if ($request_method != GET) { return 405; }
'';
};
locations."= /world-of-sosal/SHA256SUMS" = {
extraConfig = ''
root /var/www/store;
default_type text/plain;
add_header Content-Disposition "attachment" always;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
try_files $uri =404;
if ($request_method != GET) { return 405; }
'';
};
locations."= /world-of-sosal/releases/" = {
extraConfig = ''
return 404;
'';
};
locations."~ ^/world-of-sosal/releases/[A-Za-z0-9][A-Za-z0-9._-]*\\.mrpack$" = {
extraConfig = ''
root /var/www/store;
default_type application/zip;
add_header Content-Disposition "attachment" always;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
if ($request_method != GET) { return 405; }
'';
};
locations."/world-of-sosal/" = {
extraConfig = ''
autoindex off;
limit_except GET {
deny all;
}
return 404;
'';
};
};
virtualHosts."lessons.${domain}" = {
enableACME = true;
forceSSL = true;
};
virtualHosts."snuff.${domain}" = {
enableACME = true;
@@ -346,6 +534,9 @@ in {
extraConfig = ''
proxy_pass http://127.0.0.1:11011/;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
'';
};
};
@@ -355,15 +546,21 @@ in {
gitea = {
enable = true;
package = pkgs.hectic.gitea-heatmap;
# Keep LFS storage limited to accounts provisioned by administrators.
settings.service.DISABLE_REGISTRATION = true;
settings.session.COOKIE_SECURE = true;
settings.actions.ENABLED = true;
# Long CUDA builds must not hit Gitea's default three-hour task watchdog.
settings.actions.ENDLESS_TASK_TIMEOUT = "8h";
settings.server = {
HTTP_ADDR = "127.0.0.1";
HTTP_PORT = 11011;
SSH_PORT = sshPort;
ROOT_URL = "https://gitea.${domain}/";
SSH_PORT = giteaSshPort;
SSH_DOMAIN = "hectic-lab.com";
};
lfs.enable = true;
settings.lfs.LFS_MAX_FILE_SIZE = 536870912;
database = {
createDatabase = true;
type = "postgres";
+16
View File
@@ -0,0 +1,16 @@
{ domain, ... }:
{
config,
...
}:
{
hectic.services.immich = {
enable = true;
domain = "immich.${domain}";
storageBox = {
enable = true;
credentialsFile = config.sops.secrets."immich/storage-box".path;
};
};
}
@@ -0,0 +1,8 @@
{ ... }:
{
imports = [ (import ../../module/generic/minecraft-public-relay.nix { }) ];
services.minecraft-public-relay = {
enable = true;
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKNWWegOVTOF3EOmam32iP7sMybULMTxsXuC+cEGITQ8 minecraft-wow-relay";
};
}
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WorldOfSosal</title>
</head>
<body>
<main>
<h1>WorldOfSosal</h1>
<p><a href="https://store.hectic-lab.com/minecraft/world-of-sosal/">Install in Prism with automatic mod updates</a></p>
<p><a href="https://store.hectic-lab.com/world-of-sosal/latest.mrpack">Download latest pack</a></p>
<p><a href="prismlauncher://import?url=https%3A%2F%2Fstore.hectic-lab.com%2Fworld-of-sosal%2Flatest.mrpack">Import latest pack in Prism Launcher</a></p>
<p><a href="https://store.hectic-lab.com/world-of-sosal/SHA256SUMS">SHA-256 checksums</a></p>
<p>Updates are manual. Packs imported from arbitrary URLs do not update automatically.</p>
</main>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
# WorldOfSosal pack publishing
The public endpoint is `https://store.hectic-lab.com/world-of-sosal/`. Nix
deploys only its landing page and nginx configuration. Pack files, the checksum
manifest, and `latest.mrpack` stay under `/var/www/store/world-of-sosal` on the
host and never enter Git or the Nix store.
## Publish an uploaded pack
Run these commands on `hectic-lab` as root after the Storage Box pack has
already been uploaded to a local staging path. Pick a stable version name; do
not replace an existing versioned release.
```sh
set -eu
source_pack=/path/to/already-uploaded/WorldOfSosal.mrpack
version=2026-09-16
root=/var/www/store/world-of-sosal
release_name="WorldOfSosal-${version}.mrpack"
release_path="$root/releases/$release_name"
printf '%s %s\n' \
f8c18acb9208e4592725632ae50dab4f9c308483b34fd43a6507c74fdbf8169f \
"$source_pack" | sha256sum --check --status
test ! -e "$release_path"
install -o root -g nginx -m 0640 "$source_pack" "$release_path.new"
mv -T "$release_path.new" "$release_path"
manifest="$root/.SHA256SUMS.$$"
(cd "$root/releases" && sha256sum -- *.mrpack) > "$manifest"
chown root:nginx "$manifest"
chmod 0640 "$manifest"
mv -Tf "$manifest" "$root/SHA256SUMS"
latest="$root/.latest.mrpack.$$"
ln -s "releases/$release_name" "$latest"
mv -Tf "$latest" "$root/latest.mrpack"
```
Versioned releases use a one-year immutable cache policy. `latest.mrpack` and
`SHA256SUMS` disable caching so an atomic replacement becomes visible quickly.
The manifest is available at
`https://store.hectic-lab.com/world-of-sosal/SHA256SUMS`.
Import the current pack in Prism Launcher with:
```text
prismlauncher://import?url=https%3A%2F%2Fstore.hectic-lab.com%2Fworld-of-sosal%2Flatest.mrpack
```
Direct URL imports do not auto-update. Repeat the publication and import steps
for each new pack version.
+16
View File
@@ -1,3 +1,17 @@
# Current Minecraft access (2026-09-18)
WorldOfSosal on the WoW map uses `store.hectic-lab.com:25568` publicly.
The game server is `neuro:25567`; a restricted persistent reverse SSH tunnel
connects it to the public relay. See `docs/minecraft-prism.md` and the Nix modules
`minecraft/public-tunnel.nix` / `hectic-lab/minecraft-wow-proxy.nix`.
Verified LAN: neuro is `192.168.88.10`, gateway `192.168.88.1`.
SSH access is `95.31.254.84:34457`. Direct external Minecraft TCP probes timed out,
and no UPnP IGD was discovered. The old TP-Link network and manual port-forward
instructions below describe the previous network, not the active configuration.
---
# Router Access (TP-Link)
The server `neuro` is behind a NAT router at `192.168.0.1`.
@@ -50,6 +64,8 @@ Ports that need to be forwarded from router to `192.168.0.10`:
| 5269 | 5269 | TCP | XMPP (s2s) |
| 10000 | 10000 | UDP | Jitsi Videobridge |
| 25565 | 25565 | TCP | Minecraft |
| 25567 | 25567 | TCP | Minecraft WoW Mine map |
| 25568 | 25568 | TCP | Minecraft World of Sosal |
## Troubleshooting
+5 -3
View File
@@ -17,10 +17,12 @@ in self.lib.nixpkgs-lib.nixosSystem {
];
config.allowUnfreePredicate = pkg:
self.lib.cudaUnfreePredicate pkg || builtins.elem (self.lib.nixpkgs-lib.getName pkg) [
"minecraft-server"
"neoforge"
"minecraft-server"
"neoforge"
"steamcmd"
"steam-unwrapped"
"nvidia-x11"
"nvidia-x11"
];
# jitsi-meet depends on libolm which is marked insecure (CVE-2024-4519x)
config.permittedInsecurePackages = [
-61
View File
@@ -1,61 +0,0 @@
{
pkgs,
...
}:
{
services.minecraft-servers = {
enable = true;
eula = true;
openFirewall = true;
servers = {
vanilla = {
enable = true;
jvmOpts = "-Xmx6G -Xms2G";
package = pkgs.minecraftServers.vanilla-1_21_11;
serverProperties = {
server-port = 25565;
difficulty = "hard";
online-mode = true;
view-distance = 32;
level-seed = "8306359138650378643";
pause-when-empty-seconds = 0;
};
};
createAeronautics = {
enable = true;
jvmOpts = "-Xmx8G -Xms2G";
package = pkgs.minecraftServers.neoforge-1_21_1;
symlinks = {
mods = pkgs.linkFarmFromDrvs "mods" (
builtins.attrValues {
Sable = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/T9PomCSv/versions/g8CObHcP/sable-neoforge-1.21.1-1.1.3.jar";
sha512 = "8180e214681c171c9e3b7fa307f7a92bd7de0b8125d671291425f04a4ba26b408758d8ea80a6386d8e73bb1e6b02caf3f20afb9b91ecedd48c37ed44363ac961";
};
Create = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar";
sha512 = "11cc8fc049d2f67f6548c7abfada6b82a3adb5c7ca410a742de04bbca76e03862c518721b88d806f6e6d768a4d68531fdb903a85859b25d1484d550cc7bafd4b";
};
CreateAeronautics = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/oWaK0Q19/versions/1sv6OtSz/create-aeronautics-bundled-1.21.1-1.1.3.jar";
sha512 = "94831bc4702b3864524258fa0a73a50ab3cd37e9c157b5c6688a6845b866ec5838452804050b55e490549d91dad909fc37f0d619f354c5676e2e2651b9c15ec6";
};
}
);
};
serverProperties = {
server-port = 25566;
difficulty = "hard";
online-mode = true;
view-distance = 20;
pause-when-empty-seconds = 0;
};
};
};
};
}
@@ -0,0 +1,21 @@
{ pkgs, ... }:
{
services.minecraft-servers.servers.createAeronautics = {
enable = true;
jvmOpts = "-Xmx8G -Xms2G";
package = pkgs.minecraftServers.neoforge-1_21_1;
symlinks = {
mods = import ./mods.nix { inherit pkgs; };
};
serverProperties = {
server-port = 25566;
difficulty = "hard";
online-mode = true;
view-distance = 20;
pause-when-empty-seconds = 0;
};
};
}
+17
View File
@@ -0,0 +1,17 @@
{
imports = [
./vanilla.nix
./create-aeronautics.nix
./wow-mine-map.nix
./world-of-sosal.nix
./public-tunnel.nix
./world-import.nix
./modpack-import.nix
];
services.minecraft-servers = {
enable = true;
eula = true;
openFirewall = true;
};
}
@@ -0,0 +1,460 @@
{
config,
lib,
pkgs,
...
}: let
inherit (lib) mkOption types;
cfg = config.services.minecraft-modpack-imports;
enabledImports = lib.filterAttrs (_: import: import.enable) cfg;
dataDir = config.services.minecraft-servers.dataDir;
minecraftServers = config.services.minecraft-servers.servers;
targetServers = lib.mapAttrsToList (_: import: import.serverName) enabledImports;
importerUser = name: let
descriptiveName = "mc-pack-${name}";
in
if builtins.stringLength descriptiveName <= 31
then descriptiveName
else "mc-pack-${builtins.substring 0 16 (builtins.hashString "sha256" name)}";
stateDirectory = cacheDir:
if lib.hasPrefix "/var/lib/" cacheDir
then lib.removePrefix "/var/lib/" cacheDir
else null;
stateDirectoryCompatible = cacheDir: let
relative = stateDirectory cacheDir;
components = lib.splitString "/" (if relative == null then "" else relative);
in
relative != null
&& relative != ""
&& lib.all (component: component != "" && component != "." && component != "..") components;
escapeSftp = value:
"\"${lib.replaceStrings ["\\" "\""] ["\\\\" "\\\""] value}\"";
escapeTmpfiles = value:
lib.replaceStrings ["%" " " "\t"] ["%%" "\\x20" "\\x09"] value;
importerServices = lib.mkMerge (lib.mapAttrsToList (name: import: let
user = importerUser name;
unitName = "minecraft-modpack-import-${name}";
serverUnit = "minecraft-server-${import.serverName}.service";
cacheStateDirectory = stateDirectory import.cacheDir;
serverDir = "${dataDir}/${import.serverName}";
in {
${unitName} = {
description = "Import Minecraft Modrinth pack ${name}";
before = [ serverUnit ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
path = [
pkgs.coreutils
pkgs.curl
pkgs.findutils
pkgs.jq
pkgs.openssh
pkgs.unar
];
serviceConfig = {
Type = "oneshot";
User = user;
Group = user;
RemainAfterExit = true;
TimeoutStartSec = import.timeout;
ProtectSystem = "strict";
ProtectHome = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
NoNewPrivileges = true;
RestrictSUIDSGID = true;
LockPersonality = true;
CapabilityBoundingSet = [ "" ];
ReadWritePaths = [ import.cacheDir serverDir ];
UMask = "0007";
} // lib.optionalAttrs (stateDirectoryCompatible import.cacheDir) {
StateDirectory = cacheStateDirectory;
};
script = ''
set -eu
umask 007
cache_dir=${lib.escapeShellArg import.cacheDir}
server_dir=${lib.escapeShellArg serverDir}
archive_name=${lib.escapeShellArg import.archiveName}
archive="$cache_dir/$archive_name"
temporary_archive="$cache_dir/.$archive_name.$$"
extraction_dir="$cache_dir/.extract-${name}.$$"
staging_dir="$cache_dir/.stage-${name}.$$"
managed_paths="$cache_dir/managed-paths"
new_managed_paths="$cache_dir/.managed-paths.$$"
key=${lib.escapeShellArg import.sshKeyFile}
cleanup() {
rm -f "$temporary_archive" "$new_managed_paths"
rm -rf "$extraction_dir" "$staging_dir"
}
trap cleanup EXIT
safe_relative_path() {
case "$1" in
""|/*|*\\*|.|..|./*|../*|*/./*|*/../*|*/.|*/..)
return 1
;;
esac
return 0
}
archive_valid() {
[ -f "$archive" ] && printf '%s %s\n' \
${lib.escapeShellArg import.archiveSha256} \
"$archive" | sha256sum -c --status
}
archive_entries_valid() {
lsar -json "$archive" | jq -e '
(.lsarContents // .entries) as $entries
| ($entries | type == "array")
and ($entries | all(.[];
(.XADFileName // .XADPath) as $path
| ($path | type == "string")
and ($path | startswith("/") | not)
and ($path | contains("\\") | not)
and ($path | test("[[:cntrl:]]") | not)
and ([$path | split("/")[] | select(. == "" or . == "." or . == "..")] | length == 0)
and ((.XADIsSymbolicLink // false) | not)
and ((.XADIsHardLink // false) | not)
and ((.XADIsDevice // false) | not)
and ((.XADIsFIFO // false) | not)
and ((.XADIsSocket // false) | not)
)
)
' >/dev/null
}
mkdir -p "$cache_dir" "$server_dir"
chmod 0700 "$cache_dir"
if ! archive_valid; then
rm -f "$archive"
downloaded=false
attempt=1
while [ "$attempt" -le ${toString import.retries} ]; do
rm -f "$temporary_archive"
if sftp \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/etc/ssh/ssh_known_hosts \
-i "$key" \
-b - \
${lib.escapeShellArg "${import.remoteUser}@${import.remoteHost}"} <<EOF
get ${escapeSftp import.remotePath} "$temporary_archive"
EOF
then
if printf '%s %s\n' \
${lib.escapeShellArg import.archiveSha256} \
"$temporary_archive" | sha256sum -c --status; then
mv "$temporary_archive" "$archive"
downloaded=true
break
fi
fi
rm -f "$temporary_archive"
attempt=$((attempt + 1))
done
if [ "$downloaded" != true ]; then
echo "Unable to download verified Minecraft modpack ${name}" >&2
exit 1
fi
fi
if ! archive_entries_valid; then
echo "Modpack archive contains unsafe entries" >&2
exit 1
fi
mkdir -p "$extraction_dir" "$staging_dir"
unar -quiet -output-directory "$extraction_dir" "$archive"
find "$extraction_dir" \
\( -type l -o -type b -o -type c -o -type p -o -type s \) \
-delete
manifest=$(find "$extraction_dir" -type f -name modrinth.index.json -print)
if [ -z "$manifest" ] || [ "$(printf '%s\n' "$manifest" | wc -l)" -ne 1 ]; then
echo "Modpack must contain exactly one modrinth.index.json" >&2
exit 1
fi
pack_root=$(dirname "$manifest")
if ! jq -e \
--argjson expectedDependencies ${lib.escapeShellArg (builtins.toJSON import.expectedDependencies)} \
'
. as $manifest
| .formatVersion == 1
and ($expectedDependencies | to_entries | all(.[];
$manifest.dependencies[.key] == .value
))
and (.files | type == "array")
and all(.files[];
((.env.server // "required") == "unsupported")
or (
(.path | type == "string")
and (.path | length > 0)
and (.path | startswith("mods/"))
and (.path | startswith("/") | not)
and (.path | contains("\\") | not)
and (.path | test("[[:cntrl:]]") | not)
and ([.path | split("/")[] | select(. == "" or . == "." or . == "..")] | length == 0)
and (.hashes.sha512 | type == "string")
and (.hashes.sha512 | test("^[0-9a-fA-F]{128}$"))
and (.downloads | type == "array")
and (.downloads | length > 0)
and (.downloads[0] | type == "string")
and (.downloads[0] | startswith("https://"))
and (.downloads[0] | test("[[:cntrl:]]") | not)
)
)
' "$manifest" >/dev/null; then
echo "Modpack manifest contains unsafe or invalid server files" >&2
exit 1
fi
: > "$new_managed_paths"
jq -r '
.files[]
| select((.env.server // "required") != "unsupported")
| [.path, .hashes.sha512, .downloads[0]]
| @tsv
' "$manifest" |
while IFS="$(printf '\t')" read -r relative expected_hash url; do
safe_relative_path "$relative" || exit 1
destination="$staging_dir/$relative"
mkdir -p "$(dirname "$destination")"
curl --fail --location --silent --show-error \
--retry ${toString import.retries} \
--output "$destination" \
"$url"
if ! printf '%s %s\n' "$expected_hash" "$destination" |
sha512sum -c --status; then
echo "SHA-512 mismatch for $relative" >&2
exit 1
fi
printf '%s\n' "$relative" >> "$new_managed_paths"
done
overrides_dir="$pack_root/overrides"
if [ -d "$overrides_dir" ]; then
find "$overrides_dir" \
\( -type l -o -type b -o -type c -o -type p -o -type s \) \
-delete
cp -R "$overrides_dir/." "$staging_dir/"
find "$overrides_dir" -type f -printf '%P\n' |
while IFS= read -r relative; do
safe_relative_path "$relative" || exit 1
printf '%s\n' "$relative"
done >> "$new_managed_paths"
fi
# Nix Minecraft manages eula.txt via a symlink. Only reject symlinks
# in destinations we actually touch, including their parent directories.
safe_target_path() {
safe_relative_path "$1" || return 1
target="$server_dir/$1"
while [ "$target" != "$server_dir" ]; do
if [ -L "$target" ]; then
echo "Modpack destination contains symlink: $target" >&2
return 1
fi
target=$(dirname "$target")
done
}
while IFS= read -r relative; do
safe_target_path "$relative" || exit 1
done < "$new_managed_paths"
if [ -f "$managed_paths" ]; then
while IFS= read -r relative; do
safe_target_path "$relative" || exit 1
done < "$managed_paths"
fi
while IFS= read -r relative; do
safe_relative_path "$relative" || exit 1
source_file="$staging_dir/$relative"
target_file="$server_dir/$relative"
install -d -m 0770 -g minecraft "$(dirname "$target_file")"
install -m 0660 -g minecraft "$source_file" "$target_file"
done < "$new_managed_paths"
if [ -f "$managed_paths" ]; then
while IFS= read -r old_relative; do
safe_relative_path "$old_relative" || {
echo "Unsafe path in previous managed-paths file" >&2
exit 1
}
keep=false
while IFS= read -r relative; do
if [ "$old_relative" = "$relative" ]; then
keep=true
break
fi
done < "$new_managed_paths"
if [ "$keep" != true ]; then
rm -f "$server_dir/$old_relative"
fi
done < "$managed_paths"
fi
mv "$new_managed_paths" "$managed_paths"
'';
};
"minecraft-server-${import.serverName}" = {
requires = [ "${unitName}.service" ];
after = [ "${unitName}.service" ];
};
}) enabledImports);
in {
options.services.minecraft-modpack-imports = mkOption {
default = { };
type = types.attrsOf (types.submodule ({ name, ... }: {
options = {
enable = lib.mkEnableOption "Minecraft Modrinth pack import ${name}";
serverName = mkOption {
type = types.str;
description = "minecraft-servers server receiving imported pack";
};
remoteHost = mkOption {
type = types.str;
description = "SSH host serving Modrinth pack archive";
};
remoteUser = mkOption {
type = types.str;
description = "SSH user used to download Modrinth pack archive";
};
remotePath = mkOption {
type = types.str;
description = "Remote path to Modrinth pack archive";
};
archiveName = mkOption {
type = types.str;
description = "Archive file name inside cache directory";
};
cacheDir = mkOption {
type = types.str;
default = "/var/lib/minecraft-modpacks/${name}";
description = "Persistent Modrinth archive and importer state directory";
};
archiveSha256 = mkOption {
type = types.strMatching "[0-9a-fA-F]{64}";
description = "Expected SHA-256 digest of Modrinth pack archive";
};
expectedDependencies = mkOption {
type = types.attrsOf types.str;
default = { };
description = "Required dependency versions in modrinth.index.json";
};
sshKeyFile = mkOption {
type = types.str;
description = "Runtime path to private SSH key";
};
hostPublicKey = mkOption {
type = types.str;
description = "Pinned SSH host public key";
};
retries = mkOption {
type = types.ints.positive;
default = 3;
description = "Maximum SFTP attempts and curl retry count";
};
timeout = mkOption {
type = types.str;
default = "30min";
description = "Importer service start timeout";
};
};
}));
description = "Modrinth packs imported before selected Minecraft servers start";
};
config = lib.mkIf (enabledImports != { }) {
assertions = lib.flatten (lib.mapAttrsToList (name: import: [
{
assertion = builtins.match "[A-Za-z0-9_-]+" name != null;
message = "services.minecraft-modpack-imports.${name}: name must contain only letters, digits, underscores, or hyphens";
}
{
assertion = builtins.match "/.*" import.cacheDir != null;
message = "services.minecraft-modpack-imports.${name}.cacheDir must be absolute";
}
{
assertion = builtins.match "/var/lib(/[A-Za-z0-9][A-Za-z0-9._-]*)+" import.cacheDir != null;
message = "services.minecraft-modpack-imports.${name}.cacheDir must be beneath /var/lib with safe path components";
}
{
assertion = builtins.match "[A-Za-z0-9_-]+" import.serverName != null;
message = "services.minecraft-modpack-imports.${name}.serverName must contain only letters, digits, underscores, or hyphens";
}
{
assertion = !lib.hasInfix "\n" import.remotePath && !lib.hasInfix "\r" import.remotePath;
message = "services.minecraft-modpack-imports.${name}.remotePath must not contain newlines";
}
{
assertion = builtins.hasAttr import.serverName minecraftServers
&& (builtins.getAttr import.serverName minecraftServers).enable;
message = "services.minecraft-modpack-imports.${name}.serverName must name an enabled Minecraft server";
}
{
assertion = builtins.match "[A-Za-z0-9._-]+" import.archiveName != null
&& import.archiveName != "."
&& import.archiveName != ".."
&& import.archiveName != "managed-paths";
message = "services.minecraft-modpack-imports.${name}.archiveName must be a file name";
}
{
assertion = lib.length (lib.unique targetServers) == lib.length targetServers;
message = "services.minecraft-modpack-imports: each server target must be unique";
}
]) enabledImports);
users.groups = lib.mapAttrs' (name: _: lib.nameValuePair (importerUser name) { }) enabledImports;
users.users = lib.mapAttrs' (name: _: let
user = importerUser name;
in lib.nameValuePair user {
description = "Minecraft modpack importer ${name}";
isSystemUser = true;
group = user;
extraGroups = [ "minecraft" ];
}) enabledImports;
programs.ssh.knownHosts = lib.mapAttrs' (name: import:
lib.nameValuePair "minecraft-modpack-import-${name}" {
hostNames = [ import.remoteHost ];
publicKey = import.hostPublicKey;
}) enabledImports;
systemd.tmpfiles.rules = lib.flatten (lib.mapAttrsToList (name: import:
lib.optional (!stateDirectoryCompatible import.cacheDir)
"d ${escapeTmpfiles import.cacheDir} 0700 ${importerUser name} ${importerUser name} -") enabledImports);
systemd.services = importerServices;
};
}
+18
View File
@@ -0,0 +1,18 @@
{ pkgs }:
pkgs.linkFarmFromDrvs "create-aeronautics-mods" (
builtins.attrValues {
Sable = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/T9PomCSv/versions/g8CObHcP/sable-neoforge-1.21.1-1.1.3.jar";
sha512 = "8180e214681c171c9e3b7fa307f7a92bd7de0b8125d671291425f04a4ba26b408758d8ea80a6386d8e73bb1e6b02caf3f20afb9b91ecedd48c37ed44363ac961";
};
Create = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar";
sha512 = "11cc8fc049d2f67f6548c7abfada6b82a3adb5c7ca410a742de04bbca76e03862c518721b88d806f6e6d768a4d68531fdb903a85859b25d1484d550cc7bafd4b";
};
CreateAeronautics = pkgs.fetchurl {
url = "https://cdn.modrinth.com/data/oWaK0Q19/versions/1sv6OtSz/create-aeronautics-bundled-1.21.1-1.1.3.jar";
sha512 = "94831bc4702b3864524258fa0a73a50ab3cd37e9c157b5c6688a6845b866ec5838452804050b55e490549d91dad909fc37f0d619f354c5676e2e2651b9c15ec6";
};
}
)
@@ -0,0 +1,363 @@
{
"build": {
"version": "21.1.250",
"src": {
"url": "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.250/neoforge-21.1.250-installer.jar",
"hash": "sha256-DkepG6ITmo20v3Ynrwgfe1eJtQi7A57o3qEnK3lpPWA="
},
"libraries": [
"net.neoforged.fancymodloader:earlydisplay:4.0.44",
"net.neoforged.fancymodloader:loader:4.0.44",
"net.neoforged.accesstransformers:at-modlauncher:10.0.1",
"net.neoforged:accesstransformers:10.0.1",
"net.neoforged:bus:8.0.5",
"net.neoforged:coremods:7.0.3",
"cpw.mods:modlauncher:11.0.5",
"net.neoforged:mergetool:2.0.0:api",
"com.electronwill.night-config:toml:3.8.3",
"com.electronwill.night-config:core:3.8.3",
"net.neoforged:JarJarSelector:0.4.1",
"net.neoforged:JarJarMetadata:0.4.1",
"org.apache.maven:maven-artifact:3.8.5",
"net.jodah:typetools:0.6.3",
"net.minecrell:terminalconsoleappender:1.3.0",
"net.fabricmc:sponge-mixin:0.15.2+mixin.0.8.7",
"org.openjdk.nashorn:nashorn-core:15.4",
"org.apache.commons:commons-lang3:3.14.0",
"cpw.mods:bootstraplauncher:2.0.2",
"cpw.mods:securejarhandler:3.0.8",
"org.ow2.asm:asm-commons:9.10.1",
"org.ow2.asm:asm-util:9.10.1",
"org.ow2.asm:asm-analysis:9.10.1",
"org.ow2.asm:asm-tree:9.10.1",
"org.ow2.asm:asm:9.10.1",
"net.neoforged:JarJarFileSystems:0.4.1",
"net.sf.jopt-simple:jopt-simple:5.0.4",
"org.slf4j:slf4j-api:2.0.9",
"org.antlr:antlr4-runtime:4.13.1",
"com.mojang:logging:1.2.7",
"org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1",
"org.apache.logging.log4j:log4j-core:2.22.1",
"org.apache.logging.log4j:log4j-api:2.22.1",
"org.jline:jline-reader:3.20.0",
"org.jline:jline-terminal:3.20.0",
"commons-io:commons-io:2.15.1",
"net.minecraftforge:srgutils:0.4.15",
"com.google.guava:guava:32.1.2-jre",
"com.google.guava:failureaccess:1.0.1",
"com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava",
"com.google.code.findbugs:jsr305:3.0.2",
"org.checkerframework:checker-qual:3.33.0",
"com.google.errorprone:error_prone_annotations:2.18.0",
"com.google.j2objc:j2objc-annotations:2.8",
"com.google.code.gson:gson:2.10.1",
"org.codehaus.plexus:plexus-utils:3.3.0",
"com.machinezoo.noexception:noexception:1.7.1",
"net.neoforged:neoform:1.21.1-20240808.144430@zip",
"net.neoforged.installertools:binarypatcher:2.1.2:fatjar",
"net.neoforged:AutoRenamingTool:2.0.3:all",
"net.neoforged.installertools:installertools:2.1.2",
"net.neoforged:srgutils:1.0.0",
"net.md-5:SpecialSource:1.11.0",
"com.google.code.gson:gson:2.8.9",
"de.siegmar:fastcsv:2.0.0",
"org.ow2.asm:asm-commons:9.3",
"net.neoforged.installertools:cli-utils:2.1.2",
"com.google.guava:guava:20.0",
"com.opencsv:opencsv:4.4",
"org.ow2.asm:asm-analysis:9.3",
"org.ow2.asm:asm-tree:9.3",
"org.ow2.asm:asm:9.3",
"org.apache.commons:commons-text:1.3",
"org.apache.commons:commons-lang3:3.8.1",
"commons-beanutils:commons-beanutils:1.9.3",
"org.apache.commons:commons-collections4:4.2",
"commons-logging:commons-logging:1.2",
"commons-collections:commons-collections:3.2.2",
"net.neoforged.installertools:jarsplitter:2.1.2",
"net.neoforged:neoforge:21.1.250:universal"
]
},
"libraryLocks": {
"net.neoforged.fancymodloader:earlydisplay:4.0.44": {
"url": "https://maven.neoforged.net/releases/net/neoforged/fancymodloader/earlydisplay/4.0.44/earlydisplay-4.0.44.jar",
"hash": "sha1-yyr88VZyGA7KHEmxybEgiVHvMi4="
},
"net.neoforged.fancymodloader:loader:4.0.44": {
"url": "https://maven.neoforged.net/releases/net/neoforged/fancymodloader/loader/4.0.44/loader-4.0.44.jar",
"hash": "sha1-Y0M2CLaDAqRC49uoeIDlT0LqBYM="
},
"net.neoforged.accesstransformers:at-modlauncher:10.0.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/accesstransformers/at-modlauncher/10.0.1/at-modlauncher-10.0.1.jar",
"hash": "sha1-WrpQICrO6tCGvAn7wnUcnwXLSJA="
},
"net.neoforged:accesstransformers:10.0.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/accesstransformers/10.0.1/accesstransformers-10.0.1.jar",
"hash": "sha1-/YO1cl926ukRXpNV+hxFampEFAA="
},
"net.neoforged:bus:8.0.5": {
"url": "https://maven.neoforged.net/releases/net/neoforged/bus/8.0.5/bus-8.0.5.jar",
"hash": "sha1-Wy0zKFq10VVOl5itmMQNbqOGi9U="
},
"net.neoforged:coremods:7.0.3": {
"url": "https://maven.neoforged.net/releases/net/neoforged/coremods/7.0.3/coremods-7.0.3.jar",
"hash": "sha1-CRR+b2OLQnKzvV/I+SrTeAJRLGw="
},
"cpw.mods:modlauncher:11.0.5": {
"url": "https://maven.neoforged.net/releases/cpw/mods/modlauncher/11.0.5/modlauncher-11.0.5.jar",
"hash": "sha1-uPDUkpT3M/22FzkxsmNVPpQ9yVA="
},
"net.neoforged:mergetool:2.0.0:api": {
"url": "https://maven.neoforged.net/releases/net/neoforged/mergetool/2.0.0/mergetool-2.0.0-api.jar",
"hash": "sha1-Uv4ZSb5k4zA6q6qiHjFfVR25yfQ="
},
"com.electronwill.night-config:toml:3.8.3": {
"url": "https://maven.neoforged.net/releases/com/electronwill/night-config/toml/3.8.3/toml-3.8.3.jar",
"hash": "sha1-kLL9bvy0p9W5gQz3j52CSZTXF/I="
},
"com.electronwill.night-config:core:3.8.3": {
"url": "https://maven.neoforged.net/releases/com/electronwill/night-config/core/3.8.3/core-3.8.3.jar",
"hash": "sha1-tEKpXwnjSZJ/WpRey1lEVYcPz08="
},
"net.neoforged:JarJarSelector:0.4.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/JarJarSelector/0.4.1/JarJarSelector-0.4.1.jar",
"hash": "sha1-+zzHpYryKtKICtuYr21RgSjEfa4="
},
"net.neoforged:JarJarMetadata:0.4.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/JarJarMetadata/0.4.1/JarJarMetadata-0.4.1.jar",
"hash": "sha1-+NoDaD3IFpRVbcPhd8Xju3eub8s="
},
"org.apache.maven:maven-artifact:3.8.5": {
"url": "https://maven.neoforged.net/releases/org/apache/maven/maven-artifact/3.8.5/maven-artifact-3.8.5.jar",
"hash": "sha1-RDP1DAfevvrtBVO9AGj09I1EkxM="
},
"net.jodah:typetools:0.6.3": {
"url": "https://maven.neoforged.net/releases/net/jodah/typetools/0.6.3/typetools-0.6.3.jar",
"hash": "sha1-oBqqbdrqnsB+xPIJSHt6RqUmKDo="
},
"net.minecrell:terminalconsoleappender:1.3.0": {
"url": "https://maven.neoforged.net/releases/net/minecrell/terminalconsoleappender/1.3.0/terminalconsoleappender-1.3.0.jar",
"hash": "sha1-tWLpu2EjXJUg4mKCze5x+PgC0fw="
},
"net.fabricmc:sponge-mixin:0.15.2+mixin.0.8.7": {
"url": "https://maven.neoforged.net/releases/net/fabricmc/sponge-mixin/0.15.2+mixin.0.8.7/sponge-mixin-0.15.2+mixin.0.8.7.jar",
"hash": "sha1-KvLwIdjgKgIg3CenpytGZtZtRMo="
},
"org.openjdk.nashorn:nashorn-core:15.4": {
"url": "https://maven.neoforged.net/releases/org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar",
"hash": "sha1-9n9f+qX1Ewz2+5sTPaAMffO1MqU="
},
"org.apache.commons:commons-lang3:3.14.0": {
"url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar",
"hash": "sha1-HtRxGUsC8sbLc0oM1vbxB8Zzr64="
},
"cpw.mods:bootstraplauncher:2.0.2": {
"url": "https://maven.neoforged.net/releases/cpw/mods/bootstraplauncher/2.0.2/bootstraplauncher-2.0.2.jar",
"hash": "sha1-Gi0HbLwzsFIMus1ZEiRCeyogBH0="
},
"cpw.mods:securejarhandler:3.0.8": {
"url": "https://maven.neoforged.net/releases/cpw/mods/securejarhandler/3.0.8/securejarhandler-3.0.8.jar",
"hash": "sha1-wO+Vzs2GmaBEkFOsfZwWB0jZAs0="
},
"org.ow2.asm:asm-commons:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.10.1/asm-commons-9.10.1.jar",
"hash": "sha1-QinkxV/Y4Bwj+f6YhAdcxiiqzFA="
},
"org.ow2.asm:asm-util:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-util/9.10.1/asm-util-9.10.1.jar",
"hash": "sha1-e7nUUOjUy/n54ECWxEu/5/uoCxU="
},
"org.ow2.asm:asm-analysis:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.10.1/asm-analysis-9.10.1.jar",
"hash": "sha1-jUnxTVH2Mssdh8iNHOr1DbDYrxs="
},
"org.ow2.asm:asm-tree:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar",
"hash": "sha1-4kQzKhdWTB0VckSTmahC3jWIG+I="
},
"org.ow2.asm:asm:9.10.1": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm/9.10.1/asm-9.10.1.jar",
"hash": "sha1-raIUHAzFLuj1xIzV+kzg55TyIjY="
},
"net.neoforged:JarJarFileSystems:0.4.1": {
"url": "https://maven.neoforged.net/releases/net/neoforged/JarJarFileSystems/0.4.1/JarJarFileSystems-0.4.1.jar",
"hash": "sha1-ePWfid780DLteIsVHKag1ArOeWo="
},
"net.sf.jopt-simple:jopt-simple:5.0.4": {
"url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar",
"hash": "sha1-T9rC++kt+thqpukwFzb2tDQqP1w="
},
"org.slf4j:slf4j-api:2.0.9": {
"url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar",
"hash": "sha1-fPJyb9z7yGEPmnH7PtY5hx8xU0A="
},
"org.antlr:antlr4-runtime:4.13.1": {
"url": "https://maven.neoforged.net/releases/org/antlr/antlr4-runtime/4.13.1/antlr4-runtime-4.13.1.jar",
"hash": "sha1-FxJbrh2WViTiZe9JVS9kZaK/owc="
},
"com.mojang:logging:1.2.7": {
"url": "https://libraries.minecraft.net/com/mojang/logging/1.2.7/logging-1.2.7.jar",
"hash": "sha1-JMuV/7DjQz/W6ETATmgAnlBMocA="
},
"org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1": {
"url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar",
"hash": "sha1-1+ZpPCYGy35zNQR9e7lt7FLbVmU="
},
"org.apache.logging.log4j:log4j-core:2.22.1": {
"url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar",
"hash": "sha1-cYOiVRCgKtAMxqldOz0qfTxajcQ="
},
"org.apache.logging.log4j:log4j-api:2.22.1": {
"url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar",
"hash": "sha1-vqb+3mMo+rr9fmg2MWGn6mYFq9E="
},
"org.jline:jline-reader:3.20.0": {
"url": "https://maven.neoforged.net/releases/org/jline/jline-reader/3.20.0/jline-reader-3.20.0.jar",
"hash": "sha1-jxVBWwIqJbRz6OFsKK6RMYb/ucQ="
},
"org.jline:jline-terminal:3.20.0": {
"url": "https://maven.neoforged.net/releases/org/jline/jline-terminal/3.20.0/jline-terminal-3.20.0.jar",
"hash": "sha1-0N3McI3fUno0VMlBt7kiXMg6Ff8="
},
"commons-io:commons-io:2.15.1": {
"url": "https://libraries.minecraft.net/commons-io/commons-io/2.15.1/commons-io-2.15.1.jar",
"hash": "sha1-8RVg2hiatWOlyONRlBQVQw6TBOo="
},
"net.minecraftforge:srgutils:0.4.15": {
"url": "https://maven.neoforged.net/releases/net/minecraftforge/srgutils/0.4.15/srgutils-0.4.15.jar",
"hash": "sha1-ykCLExdZR48WTgEPrg1zmX4SX7U="
},
"com.google.guava:guava:32.1.2-jre": {
"url": "https://libraries.minecraft.net/com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar",
"hash": "sha1-XmTsfgVkVr7zpLxMb9rvceirYxg="
},
"com.google.guava:failureaccess:1.0.1": {
"url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar",
"hash": "sha1-Hc8d44Kgv5Wj2LCElUbIi6wSksk="
},
"com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava": {
"url": "https://libraries.minecraft.net/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar",
"hash": "sha1-tCFSbF8pcpWt7xyIblJGw51Kxik="
},
"com.google.code.findbugs:jsr305:3.0.2": {
"url": "https://libraries.minecraft.net/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar",
"hash": "sha1-JeouiwwziodzE71GctP+BW6njw0="
},
"org.checkerframework:checker-qual:3.33.0": {
"url": "https://libraries.minecraft.net/org/checkerframework/checker-qual/3.33.0/checker-qual-3.33.0.jar",
"hash": "sha1-3itgti2kh2RPwR9zTnPIsLQxI48="
},
"com.google.errorprone:error_prone_annotations:2.18.0": {
"url": "https://libraries.minecraft.net/com/google/errorprone/error_prone_annotations/2.18.0/error_prone_annotations-2.18.0.jar",
"hash": "sha1-ibaEJXCW9Uj6Oaffn9qkCdTU35E="
},
"com.google.j2objc:j2objc-annotations:2.8": {
"url": "https://libraries.minecraft.net/com/google/j2objc/j2objc-annotations/2.8/j2objc-annotations-2.8.jar",
"hash": "sha1-yFJw4wfnuCLxCGuTaJEkuJdo4nM="
},
"com.google.code.gson:gson:2.10.1": {
"url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar",
"hash": "sha1-s63UeNQ4K3jqILFnE5CoWAAv62w="
},
"org.codehaus.plexus:plexus-utils:3.3.0": {
"url": "https://maven.neoforged.net/releases/org/codehaus/plexus/plexus-utils/3.3.0/plexus-utils-3.3.0.jar",
"hash": "sha1-z0O1OR3mI7Nv4GaiESe674LGQCI="
},
"com.machinezoo.noexception:noexception:1.7.1": {
"url": "https://maven.neoforged.net/releases/com/machinezoo/noexception/noexception/1.7.1/noexception-1.7.1.jar",
"hash": "sha1-tlMwyY44ofkV+lSm5eykllBePwo="
},
"net.neoforged:neoform:1.21.1-20240808.144430@zip": {
"url": "https://maven.neoforged.net/releases/net/neoforged/neoform/1.21.1-20240808.144430/neoform-1.21.1-20240808.144430.zip",
"hash": "sha1-gR4r2G+izaKBLl6OUdcY6ovW0/Q="
},
"net.neoforged.installertools:binarypatcher:2.1.2:fatjar": {
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/binarypatcher/2.1.2/binarypatcher-2.1.2-fatjar.jar",
"hash": "sha1-dZtj7zk+2AQY7B6k0jPNYVLQJjc="
},
"net.neoforged:AutoRenamingTool:2.0.3:all": {
"url": "https://maven.neoforged.net/releases/net/neoforged/AutoRenamingTool/2.0.3/AutoRenamingTool-2.0.3-all.jar",
"hash": "sha1-2YkMcbQ2b4hsKxAGeCBDpqaBbrY="
},
"net.neoforged.installertools:installertools:2.1.2": {
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar",
"hash": "sha1-clJMA2L4EtiqTNtMA+m0Xitxrjs="
},
"net.neoforged:srgutils:1.0.0": {
"url": "https://maven.neoforged.net/releases/net/neoforged/srgutils/1.0.0/srgutils-1.0.0.jar",
"hash": "sha1-uf5s2rSUmDIXy8FMxvksjmxhZSY="
},
"net.md-5:SpecialSource:1.11.0": {
"url": "https://maven.neoforged.net/releases/net/md-5/SpecialSource/1.11.0/SpecialSource-1.11.0.jar",
"hash": "sha1-Q7hMS7jQHPkKKd/uwclYpLZLr0Y="
},
"com.google.code.gson:gson:2.8.9": {
"url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.8.9/gson-2.8.9.jar",
"hash": "sha1-ikMsHWgleB4hoC2y4sM8X94oM7k="
},
"de.siegmar:fastcsv:2.0.0": {
"url": "https://maven.neoforged.net/releases/de/siegmar/fastcsv/2.0.0/fastcsv-2.0.0.jar",
"hash": "sha1-thXybAPt6slmYYuTue5PTu1QquE="
},
"org.ow2.asm:asm-commons:9.3": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.3/asm-commons-9.3.jar",
"hash": "sha1-HypDLRIS9cNSrmB9e2HcriDCCvU="
},
"net.neoforged.installertools:cli-utils:2.1.2": {
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/cli-utils/2.1.2/cli-utils-2.1.2.jar",
"hash": "sha1-5aMRXrnimRF0cmdZRr6KcuSZCtY="
},
"com.google.guava:guava:20.0": {
"url": "https://maven.neoforged.net/releases/com/google/guava/guava/20.0/guava-20.0.jar",
"hash": "sha1-iVB3ASSTiOHtXdz4xB9M4b54Me8="
},
"com.opencsv:opencsv:4.4": {
"url": "https://maven.neoforged.net/releases/com/opencsv/opencsv/4.4/opencsv-4.4.jar",
"hash": "sha1-Ulkyoe30bJynWqnIPTHcGAwYaGU="
},
"org.ow2.asm:asm-analysis:9.3": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.3/asm-analysis-9.3.jar",
"hash": "sha1-SwcfIRs3w44On1mYVQGXyFk/atg="
},
"org.ow2.asm:asm-tree:9.3": {
"url": "https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.3/asm-tree-9.3.jar",
"hash": "sha1-eNLs1hMYtaWM0E+yN2NsDoa3fZc="
},
"org.ow2.asm:asm:9.3": {
"url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar",
"hash": "sha1-jmMA71HB2AGn7WLQfNIhrKOpBkA="
},
"org.apache.commons:commons-text:1.3": {
"url": "https://maven.neoforged.net/releases/org/apache/commons/commons-text/1.3/commons-text-1.3.jar",
"hash": "sha1-mr9hcIpmq15V9haaIA2/xYS1Rtk="
},
"org.apache.commons:commons-lang3:3.8.1": {
"url": "https://maven.neoforged.net/releases/org/apache/commons/commons-lang3/3.8.1/commons-lang3-3.8.1.jar",
"hash": "sha1-ZQWnKgl9knD3qee/QsQjgoMkd1U="
},
"commons-beanutils:commons-beanutils:1.9.3": {
"url": "https://maven.neoforged.net/releases/commons-beanutils/commons-beanutils/1.9.3/commons-beanutils-1.9.3.jar",
"hash": "sha1-yEVwPeM03ca0s80mg1RYyxy6Hz0="
},
"org.apache.commons:commons-collections4:4.2": {
"url": "https://maven.neoforged.net/releases/org/apache/commons/commons-collections4/4.2/commons-collections4-4.2.jar",
"hash": "sha1-VOvqCltlPTxoATHnP+gHu494xO0="
},
"commons-logging:commons-logging:1.2": {
"url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar",
"hash": "sha1-S/wSrf5IQr8HtlfwNpxMtSKVVoY="
},
"commons-collections:commons-collections:3.2.2": {
"url": "https://maven.neoforged.net/releases/commons-collections/commons-collections/3.2.2/commons-collections-3.2.2.jar",
"hash": "sha1-itcv45+oyR6q8Sqtsh4MNmH+JtU="
},
"net.neoforged.installertools:jarsplitter:2.1.2": {
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/jarsplitter/2.1.2/jarsplitter-2.1.2.jar",
"hash": "sha1-inkWvgoOWJiXvqt8g5ByYxBn5I4="
},
"net.neoforged:neoforge:21.1.250:universal": {
"url": "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.250/neoforge-21.1.250-universal.jar",
"hash": "sha1-IUxSk8hZmwqq39v6kkU7ojkYohk="
}
}
}
@@ -0,0 +1,49 @@
{ config, pkgs, ... }:
let
mkTunnel = relay: {
description = "WorldOfSosal WoW reverse tunnel to ${relay.name}";
startLimitIntervalSec = 0;
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
User = "mc-wow-tunnel";
Group = "mc-wow-tunnel";
ExecStart = "${pkgs.openssh}/bin/ssh -NT -i ${config.sops.secrets."minecraft/wow-tunnel-key".path} -o IPQoS=none -o Ciphers=aes256-ctr -o MACs=hmac-sha2-256-etm@openssh.com -o KexAlgorithms=curve25519-sha256 -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/etc/ssh/ssh_known_hosts -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o ConnectTimeout=10 -R 127.0.0.1:25577:127.0.0.1:25567 mc-wow-relay@${relay.address}";
Restart = "always";
RestartSec = 10;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
};
};
in {
users.groups.mc-wow-tunnel = { };
users.users.mc-wow-tunnel = {
isSystemUser = true;
group = "mc-wow-tunnel";
};
sops.secrets."minecraft/wow-tunnel-key" = {
sopsFile = ../../../../sus/neuro-minecraft.yaml;
owner = "mc-wow-tunnel";
group = "mc-wow-tunnel";
mode = "0400";
};
programs.ssh.knownHosts.minecraft-wow-relay = {
hostNames = [ "128.140.75.58" ];
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAFpr4DPSaJt0xeuGIfcZBJD3LsJHTdIRIs2Tt9HF+CT";
};
programs.ssh.knownHosts.minecraft-wow-relay-bfs = {
hostNames = [ "91.198.166.181" ];
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICcCn57nlWY5QyEz17kxuAbIX9PkjPwtlGzdJyhy+SQQ";
};
systemd.services.minecraft-wow-tunnel = mkTunnel {
name = "hectic-lab";
address = "128.140.75.58";
};
systemd.services.minecraft-wow-tunnel-bfs = mkTunnel {
name = "bfs.band";
address = "91.198.166.181";
};
}
+18
View File
@@ -0,0 +1,18 @@
{ pkgs, ... }:
{
services.minecraft-servers.servers.vanilla = {
enable = true;
jvmOpts = "-Xmx6G -Xms2G";
package = pkgs.minecraftServers.vanilla-1_21_11;
serverProperties = {
server-port = 25565;
difficulty = "hard";
online-mode = true;
view-distance = 32;
level-seed = "8306359138650378643";
pause-when-empty-seconds = 0;
};
};
}
@@ -0,0 +1,312 @@
{
config,
lib,
pkgs,
...
}: let
inherit (lib) mkOption types;
cfg = config.services.minecraft-world-imports;
enabledImports = lib.filterAttrs (_: import: import.enable) cfg;
dataDir = config.services.minecraft-servers.dataDir;
minecraftServers = config.services.minecraft-servers.servers;
targetPairs = lib.mapAttrsToList (_: import:
"${import.serverName}:${import.worldName}") enabledImports;
importerUser = name: let
descriptiveName = "minecraft-map-import-${name}";
in
if builtins.stringLength descriptiveName <= 31
then descriptiveName
else "mc-import-${builtins.substring 0 12 (builtins.hashString "sha256" name)}";
stateDirectory = cacheDir:
if lib.hasPrefix "/var/lib/" cacheDir
then lib.removePrefix "/var/lib/" cacheDir
else null;
stateDirectoryCompatible = cacheDir: let
relative = stateDirectory cacheDir;
components = lib.splitString "/" (if relative == null then "" else relative);
in
relative != null
&& relative != ""
&& lib.all (component: component != "" && component != "." && component != "..") components;
escapeSftp = value:
"\"${lib.replaceStrings ["\\" "\""] ["\\\\" "\\\""] value}\"";
escapeTmpfiles = value:
lib.replaceStrings ["%" " " "\t"] ["%%" "\\x20" "\\x09"] value;
importerServices = lib.mkMerge (lib.mapAttrsToList (name: import: let
user = importerUser name;
unitName = "minecraft-world-import-${name}";
serverUnit = "minecraft-server-${import.serverName}.service";
cacheStateDirectory = stateDirectory import.cacheDir;
serverDir = "${dataDir}/${import.serverName}";
worldDir = "${serverDir}/${import.worldName}";
in {
${unitName} = {
description = "Import Minecraft world ${name}";
before = [ serverUnit ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
path = [ pkgs.coreutils pkgs.findutils pkgs.openssh pkgs.unar ];
serviceConfig = {
Type = "oneshot";
User = user;
Group = user;
RemainAfterExit = true;
TimeoutStartSec = import.timeoutStartSec;
ProtectSystem = "strict";
ProtectHome = true;
PrivateDevices = true;
PrivateTmp = true;
NoNewPrivileges = true;
CapabilityBoundingSet = [ "" ];
ReadWritePaths = [ import.cacheDir dataDir ];
UMask = "0077";
} // lib.optionalAttrs (stateDirectoryCompatible import.cacheDir) {
StateDirectory = cacheStateDirectory;
};
script = ''
set -eu
umask 077
cache_dir=${lib.escapeShellArg import.cacheDir}
server_dir=${lib.escapeShellArg serverDir}
world_dir=${lib.escapeShellArg worldDir}
archive_name=${lib.escapeShellArg import.archiveName}
world_name=${lib.escapeShellArg import.worldName}
archive="$cache_dir/$archive_name"
temporary_archive="$cache_dir/.$archive_name.$$"
extraction_dir="$cache_dir/.minecraft-world-import-${name}.$$"
staged_world="$server_dir/.$world_name.import.$$"
key=${lib.escapeShellArg import.sshKeyFile}
cleanup() {
rm -f "$temporary_archive"
rm -rf "$extraction_dir" "$staged_world"
}
trap cleanup EXIT
mkdir -p "$cache_dir" "$server_dir"
chmod 0700 "$cache_dir"
# The Minecraft module creates server_dir as minecraft:minecraft
# with group write access; this importer must not chmod another user's directory.
if [ -d "$world_dir" ]; then
if [ -f "$world_dir/level.dat" ]; then
exit 0
fi
echo "Minecraft world directory exists but has no level.dat" >&2
exit 1
fi
if [ ! -f "$archive" ]; then
downloaded=false
attempt=1
while [ "$attempt" -le ${toString import.downloadRetries} ]; do
if sftp \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/etc/ssh/ssh_known_hosts \
-i "$key" \
-b - \
${lib.escapeShellArg "${import.remoteUser}@${import.remoteHost}"} <<EOF
get ${escapeSftp import.remotePath} "$temporary_archive"
EOF
then
downloaded=true
break
fi
rm -f "$temporary_archive"
sleep ${toString import.retryDelaySeconds}
attempt=$((attempt + 1))
done
if [ "$downloaded" != true ]; then
echo "Unable to download Minecraft world ${name}" >&2
exit 1
fi
mv "$temporary_archive" "$archive"
fi
if ! printf '%s %s\n' \
${lib.escapeShellArg import.archiveSha256} \
"$archive" | sha256sum -c -; then
rm -f "$archive"
echo "Cached Minecraft world ${name} checksum mismatch" >&2
exit 1
fi
mkdir -p "$extraction_dir"
unar -quiet -output-directory "$extraction_dir" "$archive"
find "$extraction_dir" \
\( -type l -o -type b -o -type c -o -type p -o -type s \) \
-delete
world_level_dat=$(find "$extraction_dir" -type f -name level.dat -print -quit)
if [ -z "$world_level_dat" ]; then
echo "Minecraft world archive contains no level.dat" >&2
exit 1
fi
mv "$(dirname "$world_level_dat")" "$staged_world"
chgrp -R minecraft "$staged_world"
chmod -R u+rwX,g+rwX,o-rwx "$staged_world"
mv "$staged_world" "$world_dir"
'';
};
"minecraft-server-${import.serverName}" = {
requires = [ "${unitName}.service" ];
after = [ "${unitName}.service" ];
};
}) enabledImports);
in {
options.services.minecraft-world-imports = mkOption {
default = { };
type = types.attrsOf (types.submodule ({ name, ... }: {
options = {
enable = lib.mkEnableOption "Minecraft world import ${name}";
serverName = mkOption {
type = types.str;
description = "minecraft-servers server receiving imported world";
};
remoteHost = mkOption {
type = types.str;
description = "SSH host serving world archive";
};
remoteUser = mkOption {
type = types.str;
description = "SSH user used to download world archive";
};
remotePath = mkOption {
type = types.str;
description = "Remote path to world archive";
};
archiveName = mkOption {
type = types.str;
description = "Archive file name inside cache directory";
};
cacheDir = mkOption {
type = types.str;
default = "/var/lib/minecraft-world-imports/${name}";
description = "Persistent archive cache directory";
};
archiveSha256 = mkOption {
type = types.strMatching "[0-9a-fA-F]{64}";
description = "Expected SHA-256 digest of world archive";
};
sshKeyFile = mkOption {
type = types.str;
description = "Runtime path to private SSH key";
};
worldName = mkOption {
type = types.str;
default = "world";
description = "World directory name beneath server directory";
};
hostPublicKey = mkOption {
type = types.str;
description = "Pinned SSH host public key";
};
downloadRetries = mkOption {
type = types.ints.positive;
default = 3;
description = "Maximum SFTP download attempts";
};
retryDelaySeconds = mkOption {
type = types.ints.unsigned;
default = 10;
description = "Delay between SFTP download attempts";
};
timeoutStartSec = mkOption {
type = types.str;
default = "30min";
description = "Importer service start timeout";
};
};
}));
description = "Minecraft worlds imported before selected servers start";
};
config = lib.mkIf (enabledImports != { }) {
assertions = lib.flatten (lib.mapAttrsToList (name: import: [
{
assertion = builtins.match "[A-Za-z0-9_-]+" name != null;
message = "services.minecraft-world-imports.${name}: name must contain only letters, digits, underscores, or hyphens";
}
{
assertion = builtins.stringLength name <= 24;
message = "services.minecraft-world-imports.${name}: name must be at most 24 characters";
}
{
assertion = builtins.match "/.*" import.cacheDir != null;
message = "services.minecraft-world-imports.${name}.cacheDir must be absolute";
}
{
assertion = builtins.match "[A-Za-z0-9_-]+" import.serverName != null;
message = "services.minecraft-world-imports.${name}.serverName must contain only letters, digits, underscores, or hyphens";
}
{
assertion = !lib.hasInfix "\n" import.remotePath && !lib.hasInfix "\r" import.remotePath;
message = "services.minecraft-world-imports.${name}.remotePath must not contain newlines";
}
{
assertion = builtins.hasAttr import.serverName minecraftServers
&& (builtins.getAttr import.serverName minecraftServers).enable;
message = "services.minecraft-world-imports.${name}.serverName must name an enabled Minecraft server";
}
{
assertion = lib.length (lib.unique targetPairs) == lib.length targetPairs;
message = "services.minecraft-world-imports: each server/world target must be unique";
}
{
assertion = builtins.match "[^/]+" import.archiveName != null;
message = "services.minecraft-world-imports.${name}.archiveName must be a file name";
}
{
assertion = builtins.match "[^/]+" import.worldName != null;
message = "services.minecraft-world-imports.${name}.worldName must be a directory name";
}
]) enabledImports);
users.groups = lib.mapAttrs' (name: _: lib.nameValuePair (importerUser name) { }) enabledImports;
users.users = lib.mapAttrs' (name: _: let
user = importerUser name;
in lib.nameValuePair user {
description = "Minecraft world importer ${name}";
isSystemUser = true;
group = user;
extraGroups = [ "minecraft" ];
}) enabledImports;
programs.ssh.knownHosts = lib.mapAttrs' (name: import:
lib.nameValuePair "minecraft-world-import-${name}" {
hostNames = [ import.remoteHost ];
publicKey = import.hostPublicKey;
}) enabledImports;
systemd.tmpfiles.rules = lib.flatten (lib.mapAttrsToList (name: import:
lib.optional (!stateDirectoryCompatible import.cacheDir)
"d ${escapeTmpfiles import.cacheDir} 0700 ${importerUser name} ${importerUser name} -") enabledImports);
systemd.services = importerServices;
};
}
@@ -0,0 +1,33 @@
{ config, ... }:
{
sops.secrets."minecraft/storage-box-pack-key" = {
sopsFile = ../../../../sus/neuro-minecraft.yaml;
owner = "mc-pack-worldOfSosal";
group = "mc-pack-worldOfSosal";
mode = "0400";
};
services.minecraft-modpack-imports.worldOfSosal = {
enable = true;
serverName = "wowMineMap";
remoteHost = "u664722.your-storagebox.de";
remoteUser = "u664722";
remotePath = "minecraft/pack/WorldOfSosal-v3.mrpack";
archiveName = "WorldOfSosal.mrpack";
cacheDir = "/var/lib/minecraft-modpacks/worldOfSosal";
archiveSha256 = "f97cf251b14f40590e97e7b39e8a8ec43dacfce6da1b02357d15e0eee10d3ade";
expectedDependencies = {
minecraft = "1.21.1";
neoforge = "21.1.250";
};
sshKeyFile = config.sops.secrets."minecraft/storage-box-pack-key".path;
hostPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5EB5p/5Hp3hGW1oHok+PIOH9Pbn7cnUiGmUEBrCVjnAw+HrKyN8bYVV0dIGllswYXwkG/+bgiBlE6IVIBAq+JwVWu1Sss3KarHY3OvFJUXZoZyRRg/Gc/+LRCE7lyKpwWQ70dbelGRyyJFH36eNv6ySXoUYtGkwlU5IVaHPApOxe4LHPZa/qhSRbPo2hwoh0orCtgejRebNtW5nlx00DNFgsvn8Svz2cIYLxsPVzKgUxs8Zxsxgn+Q/UvR7uq4AbAhyBMLxv7DjJ1pc7PJocuTno2Rw9uMZi1gkjbnmiOh6TTXIEWbnroyIhwc8555uto9melEUmWNQ+C+PwAK+MPw==";
};
# Import the map before writing modpack configuration into the same server.
systemd.services.minecraft-modpack-import-worldOfSosal = {
after = [ "minecraft-world-import-wowMineMap.service" ];
requires = [ "minecraft-world-import-wowMineMap.service" ];
};
}
@@ -0,0 +1,65 @@
{ config, pkgs, ... }:
{
sops.secrets."minecraft/storage-box-key" = {
sopsFile = ../../../../sus/neuro-minecraft.yaml;
owner = "minecraft-map-import-wowMineMap";
group = "minecraft-map-import-wowMineMap";
mode = "0400";
};
services.minecraft-world-imports.wowMineMap = {
enable = true;
serverName = "wowMineMap";
remoteHost = "u664722.your-storagebox.de";
remoteUser = "u664722";
remotePath = "minecraft/map/wow mine map.rar";
archiveName = "wow mine map.rar";
cacheDir = "/var/lib/minecraft-maps";
archiveSha256 = "bc80084de10a06b0fc2cb1651c61936b9e2fd2288f3f0fe44c964d83a393aa30";
sshKeyFile = config.sops.secrets."minecraft/storage-box-key".path;
worldName = "world";
hostPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5EB5p/5Hp3hGW1oHok+PIOH9Pbn7cnUiGmUEBrCVjnAw+HrKyN8bYVV0dIGllswYXwkG/+bgiBlE6IVIBAq+JwVWu1Sss3KarHY3OvFJUXZoZyRRg/Gc/+LRCE7lyKpwWQ70dbelGRyyJFH36eNv6ySXoUYtGkwlU5IVaHPApOxe4LHPZa/qhSRbPo2hwoh0orCtgejRebNtW5nlx00DNFgsvn8Svz2cIYLxsPVzKgUxs8Zxsxgn+Q/UvR7uq4AbAhyBMLxv7DjJ1pc7PJocuTno2Rw9uMZi1gkjbnmiOh6TTXIEWbnroyIhwc8555uto9melEUmWNQ+C+PwAK+MPw==";
};
sops.secrets."minecraft/rcon-password" = {
sopsFile = ../../../../sus/neuro-minecraft.yaml;
owner = "minecraft";
group = "minecraft";
mode = "0400";
restartUnits = [ "minecraft-server-wowMineMap.service" ];
};
# The module's automatic firewall would also expose RCON.
networking.firewall.allowedTCPPorts = [ 25567 ];
services.minecraft-servers.servers.wowMineMap = {
openFirewall = false;
extraStartPre = ''
chmod 600 server.properties
{
printf '\nrcon.password='
cat ${config.sops.secrets."minecraft/rcon-password".path}
printf '\n'
} >> server.properties
'';
enable = true;
jvmOpts = "-Xmx24G -Xms2G";
# WorldOfSosal client and server use the same pinned NeoForge.
package = pkgs.minecraftServers.neoforge-1_21_1.override (
builtins.fromJSON (builtins.readFile ./neoforge-21.1.250.json)
);
serverProperties = {
server-port = 25567;
difficulty = "hard";
online-mode = false;
enable-rcon = true;
"rcon.port" = 25575;
view-distance = 12;
simulation-distance = 8;
motd = "WorldOfSosal — World of Warcraft";
level-name = "world";
pause-when-empty-seconds = 0;
};
};
}
+69 -5
View File
@@ -24,11 +24,11 @@
ollamaPrebuilt = pkgs.stdenvNoCC.mkDerivation {
pname = "ollama";
version = "0.22.1";
version = "0.24.0";
src = pkgs.fetchurl {
url = "https://github.com/ollama/ollama/releases/download/v0.22.1/ollama-linux-amd64.tar.zst";
hash = "sha256-4nwP6PYKgkFi+Bzge0v9p2fc5PNX12LhSbPQ3gq62fs=";
url = "https://github.com/ollama/ollama/releases/download/v0.24.0/ollama-linux-amd64.tar.zst";
hash = "sha256-FcX41mugbg07RxnfiGhhLb1m4U6CdgkpuzVS4WV83Ns=";
};
nativeBuildInputs = [
@@ -70,7 +70,7 @@ in {
imports = [
self.nixosModules.hectic
inputs.sops-nix.nixosModules.sops
./minecraft.nix
./minecraft
./hardware.nix
];
@@ -139,7 +139,7 @@ in {
};
services.nginx = {
enable = true;
enable = false;
virtualHosts."bfs.band" = let
site = pkgs.runCommand "bfs-band-site" {} ''
mkdir -p $out
@@ -224,6 +224,70 @@ in {
archetype.dev.enable = true;
};
hectic.services."project-zomboid" = {
enable = true;
memory = "8g";
serverName = "servertest";
serverPropertiesFile = /var/lib/project-zomboid/server-password.ini;
serverProperties = {
Map = "Muldraugh, KY";
DoLuaChecksum = false;
Public = true;
AntiCheatSafety = 4;
AntiCheatMovement = 4;
AntiCheatSpeed = 4;
AntiCheatHit = 4;
AntiCheatPacket = 4;
AntiCheatPacketException = 4;
AntiCheatPermission = 4;
AntiCheatXP = 4;
AntiCheatFire = 4;
AntiCheatSafeHouse = 4;
AntiCheatRecipe = 4;
AntiCheatPlayer = 4;
AntiCheatChecksum = 4;
AntiCheatItem = 4;
AntiCheatNoClip = 4;
AntiCheatServerCustomization = 4;
};
workshopItems = [
"3676456221" # Lua Digital Watch Framework
"3600401184" # Realistic Temperature Mod
];
mods = [
"\\LuaDigitalWatchUI"
"\\RC_RealisticColdMod"
];
sandboxProperties = {
Zombies = 6;
ZombieConfig = {
PopulationMultiplier = 0.0;
PopulationStartMultiplier = 0.0;
PopulationPeakMultiplier = 0.0;
RespawnHours = 0.0;
RespawnUnseenHours = 0.0;
RespawnMultiplier = 0.0;
RedistributeHours = 0.0;
};
};
};
systemd.services.project-zomboid.preStart = lib.mkBefore ''
password_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password"}
properties_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password.ini"}
if [ ! -s "$password_file" ] || ! ${pkgs.gnugrep}/bin/grep -Eq '^[0-9a-f]{48}$' "$password_file"; then
umask 077
${pkgs.openssl}/bin/openssl rand -hex 24 > "$password_file"
fi
${pkgs.coreutils}/bin/chmod 0600 "$password_file"
properties_file_tmp="$( ${pkgs.coreutils}/bin/mktemp "$(dirname "$properties_file")/.server-password.ini.XXXXXX")"
${pkgs.coreutils}/bin/printf 'Password=%s\n' "$(<"$password_file")" > "$properties_file_tmp"
${pkgs.coreutils}/bin/chmod 0600 "$properties_file_tmp"
${pkgs.coreutils}/bin/mv "$properties_file_tmp" "$properties_file"
'';
sops = {
gnupg.sshKeyPaths = [ ];
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
+14
View File
@@ -6,6 +6,20 @@ in final: prev: (
legacyPackages = self.legacyPackages.${prev.stdenv.hostPlatform.system};
in {
hectic = packages // legacyPackages;
p4d = if final.stdenv.hostPlatform.system == "x86_64-linux" then prev.p4d.overrideAttrs (_: {
version = "2023.1/2797325";
src = final.fetchurl {
url = "https://ftp.perforce.com/pub/perforce/r23.1/bin.linux26x86_64/helix-core-server.tgz";
hash = "sha256-O8znAlq2XjrixG0FA4cfkgcI9t/w9QMHV0spUjYKl48=";
};
}) else prev.p4d;
p4 = prev.p4.overrideAttrs (_: {
version = "2024.1/3006289";
src = final.fetchurl {
url = "https://ftp.perforce.com/pub/perforce/r24.1/bin.tools/p4source.tgz";
hash = "sha256-z3I3cikbbSrmS7dUMMKi6edPnZk2BYAmdO+pfYRJUVQ=";
};
});
postgresql_17 = prev.postgresql_17 // {pkgs = prev.postgresql_17.pkgs // {
http = packages.pg-17-ext-http;
pg_smtp_client = packages.pg-17-ext-smtp-client;
+11 -5
View File
@@ -1,4 +1,8 @@
{ self, pkgs, inputs, ... }: let
{ self, pkgs, inputs, system, ... }: let
giteaPkgs = import inputs.nixpkgs-gitea {
inherit system;
config = pkgs.config;
};
rust = {
nativeBuildInputs = [
pkgs.pkgsBuildHost.rust-bin.stable."1.81.0".default
@@ -25,11 +29,10 @@
rev = "6ff3b71e3705e0d4081a51c21ca0379e869ba5fb";
hash = "sha256-wC/2rAsSDO83UITaFhtaf3do3aaOAko4gnKUOzwURc8=";
};
cargo = self.lib.cargoToml src;
in
buildPgrxExtension pkgs {
pname = cargo.package.name;
version = cargo.package.version;
pname = "pg_smtp_client";
version = "0.2.0";
inherit src postgresql;
@@ -136,7 +139,9 @@ in {
c-hectic = pkgs.callPackage ./c/hectic/default.nix {};
watch = pkgs.callPackage ./c/watch/default.nix {};
support-bot = pkgs.callPackage ./support-bot {};
gitea-heatmap = pkgs.callPackage ./gitea {};
gitea-heatmap = giteaPkgs.callPackage ./gitea {
nixosTests = pkgs.nixosTests;
};
gitea-runner-nix-image = pkgs.callPackage ./gitea-runner-nix-image {};
gitea-runner-controller = pkgs.callPackage ./gitea-runner-controller {};
nix-derivation-hash = pkgs.callPackage ./nix-derivation-hash {};
@@ -144,6 +149,7 @@ in {
deploy = pkgs.callPackage ./deploy { inherit inputs; };
element-web = pkgs.callPackage ./element-web {};
shellplot = pkgs.callPackage ./shellplot {};
gitea-kanban-tui = pkgs.callPackage ./gitea-kanban-tui rust.commonArgs;
which-country-rs = pkgs.callPackage ./which-country-rs {};
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
migrator = pkgs.callPackage ./migrator { inherit self; };
+1453
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "gitea-kanban-tui"
version = "0.1.0"
edition = "2021"
description = "Native-project Gitea Kanban terminal interface"
license = "MIT"
[dependencies]
clap = { version = "=4.5.20", features = ["derive"] }
crossterm = "=0.28.1"
encoding_rs = "=0.8.35"
indexmap = "=2.6.0"
instability = "=0.3.2"
ratatui = "=0.29.0"
reqwest = { version = "=0.11.27", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "=1.0.210", features = ["derive"] }
serde_json = "=1.0.128"
unicode-segmentation = "=1.12.0"
url = "=2.5.2"
+89
View File
@@ -0,0 +1,89 @@
# gitea-kanban
Native Projects Kanban tools for custom Gitea fork. `gitea-kanban-tui` provides
the keyboard interface; `gitea-kanban` provides the command-line interface.
## Configuration
Required configuration can come from flags or environment variables:
| Flag | Environment | Meaning |
| --- | --- | --- |
| `--url` | `GITEA_URL` | Gitea base URL |
| `--token-file` | `GITEA_TOKEN_FILE` | File containing API token |
| — | `GITEA_TOKEN` | API token fallback when no token file is configured |
| `--project` | `GITEA_PROJECT` | Exact native project name |
| `--project-id` | `GITEA_PROJECT_ID` | Native project ID instead of name |
| positional `OWNER` | `GITEA_OWNER` | Repository owner |
| positional `REPO` | `GITEA_REPO` | Repository name |
Native mode requires an enabled repository Projects unit, project read/write
repository permission, and a token with `read:issue`/`write:issue` scope. Issue
creation/editing follows Gitea issue permissions; deletion requires repository
admin permission in this fork. Token values are sent
only through Gitea's `Authorization` header and are never printed. Token-file
input takes precedence over environment variables. Remote URLs must use HTTPS; plain HTTP is
accepted only for loopback development.
```sh
export GITEA_URL=https://gitea.hectic-lab.com
export GITEA_TOKEN_FILE="$HOME/.config/gitea/token"
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml --bin gitea-kanban-tui -- \
--project Kanban yukkop util.nix
```
Alternatively:
```sh
nix develop .#ratatui
cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml --bin gitea-kanban-tui -- \
--project-id 1 owner repo
```
## Keys
- Arrow keys or `h`/`j`/`k`/`l`: focus column/card
- `H`/`L`: move focused card left/right
- `r`: refresh project columns and issues
- `?`: toggle help
- `q`: quit
## CLI
Both binaries use same configuration and API client. CLI commands:
```sh
gitea-kanban --project Kanban owner repo board
gitea-kanban --project Kanban owner repo create --title "Fix issue" --body "Details"
gitea-kanban --project Kanban owner repo edit 42 --title "Updated" --body "Changed"
gitea-kanban --project Kanban owner repo move 123 --column-id 7
gitea-kanban --project Kanban owner repo delete 42 --yes
gitea-kanban --project Kanban owner repo comment list 42
gitea-kanban --project Kanban owner repo comment add 42 --body "Investigating"
gitea-kanban --project Kanban owner repo comment edit 42 9001 --body "Resolved"
gitea-kanban --project Kanban owner repo comment delete 42 9001 --yes
```
`delete` requires repository admin permission. `move` takes global issue and
project-column IDs.
Native mode reads `/projects`, project columns, and each column's issues. Moves
use the issue's global API `id` and destination column `id`; optional sorting is
supported by the server API. Project names are exact, case-sensitive matches;
use `--project-id` when duplicate names exist. Empty native boards render
normally. `n` creates an issue assigned to the selected project, `e` edits the
focused issue title/body, and `d` deletes it after confirmation. In the editor,
`Enter` switches from title to body, `Tab` switches fields, `Ctrl-S` saves, and
`Esc` cancels. Closed projects, archived repositories, disabled Projects units,
unassigned issues, and cross-repository IDs are rejected by the server.
## Development
```sh
nix develop .#ratatui
cargo fmt --manifest-path package/gitea-kanban-tui/Cargo.toml -- --check
cargo clippy --manifest-path package/gitea-kanban-tui/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path package/gitea-kanban-tui/Cargo.toml
nix build .#gitea-kanban-tui
```
+32
View File
@@ -0,0 +1,32 @@
{
cargoToml,
lib,
nativeBuildInputs,
pkgs,
...
}: let
cargo = cargoToml ./Cargo.toml;
in
pkgs.rustPlatform.buildRustPackage {
pname = cargo.package.name;
version = cargo.package.version;
src = ./.;
inherit nativeBuildInputs;
cargoLock.lockFile = ./Cargo.lock;
doCheck = true;
postBuild = ''
cargo build --release --offline --bin gitea-kanban
'';
postInstall = ''
install -Dm755 target/*/release/gitea-kanban $out/bin/gitea-kanban
'';
meta = {
description = cargo.package.description;
license = lib.licenses.mit;
mainProgram = "gitea-kanban-tui";
};
}
+602
View File
@@ -0,0 +1,602 @@
use std::fmt;
use std::net::IpAddr;
use std::time::Duration;
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use reqwest::{StatusCode, Url};
use serde::de::DeserializeOwned;
use crate::config::Config;
use crate::model::{
Comment, CommentPayload, CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload,
Project, ProjectBoard, ProjectColumn,
};
pub trait GiteaApi {
fn list_projects(&self) -> Result<Vec<Project>, ApiError>;
fn list_project_columns(&self, project_id: u64) -> Result<Vec<ProjectColumn>, ApiError>;
fn list_project_column_issues(
&self,
project_id: u64,
column_id: u64,
) -> Result<Vec<Issue>, ApiError>;
fn move_project_issue(
&self,
project_id: u64,
issue_id: u64,
payload: &MoveProjectIssuePayload,
) -> Result<(), ApiError>;
fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError>;
fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError>;
fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError>;
fn list_issue_comments(&self, issue_number: u64) -> Result<Vec<Comment>, ApiError>;
fn create_issue_comment(&self, issue_number: u64, body: String) -> Result<Comment, ApiError>;
fn edit_issue_comment(&self, comment_id: u64, body: String) -> Result<Comment, ApiError>;
fn delete_issue_comment(&self, comment_id: u64) -> Result<(), ApiError>;
}
pub struct GiteaClient {
client: Client,
api_base: Url,
token: String,
}
#[derive(Debug)]
pub struct ApiError(String);
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ApiError {}
pub fn resolve_project(
projects: &[Project],
project_name: Option<&str>,
project_id: Option<u64>,
) -> Result<Project, ApiError> {
if let Some(id) = project_id {
return projects
.iter()
.find(|project| project.id == id)
.cloned()
.ok_or_else(|| ApiError(format!("project ID {id} was not found in this repository")));
}
let name = project_name.ok_or_else(|| ApiError("project selector is missing".to_owned()))?;
let matches = projects
.iter()
.filter(|project| project.title == name)
.cloned()
.collect::<Vec<_>>();
match matches.as_slice() {
[project] => Ok(project.clone()),
[] => Err(ApiError(format!(
"project named '{name}' was not found; names are matched exactly"
))),
_ => Err(ApiError(format!(
"multiple projects are named '{name}'; use --project-id"
))),
}
}
pub fn load_project_board(
client: &impl GiteaApi,
config: &Config,
) -> Result<ProjectBoard, ApiError> {
let projects = client.list_projects()?;
let project = resolve_project(&projects, config.project.as_deref(), config.project_id)?;
let columns = client.list_project_columns(project.id)?;
let issues_by_column = columns
.iter()
.map(|column| client.list_project_column_issues(project.id, column.id))
.collect::<Result<Vec<_>, _>>()?;
Ok(ProjectBoard {
project,
columns,
issues_by_column,
})
}
impl GiteaClient {
pub fn new(config: &Config) -> Result<Self, ApiError> {
let mut api_base = Url::parse(&config.base_url)
.map_err(|error| ApiError(format!("invalid Gitea URL: {error}")))?;
if !api_base.username().is_empty() || api_base.password().is_some() {
return Err(ApiError(
"Gitea URL must not contain embedded username or password".to_owned(),
));
}
require_secure_transport(&api_base)?;
api_base.set_query(None);
api_base.set_fragment(None);
api_base
.path_segments_mut()
.map_err(|_| ApiError("Gitea URL cannot be used as an API base".to_owned()))?
.pop_if_empty()
.extend(["api", "v1", "repos", &config.owner, &config.repo]);
let client = Client::builder()
.timeout(Duration::from_secs(20))
.redirect(Policy::none())
.user_agent(concat!("gitea-kanban-tui/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|error| ApiError(format!("cannot create HTTP client: {error}")))?;
Ok(Self {
client,
api_base,
token: config.token.clone(),
})
}
fn endpoint(&self, path: &str) -> Result<Url, ApiError> {
let mut url = self.api_base.clone();
url.path_segments_mut()
.map_err(|_| ApiError("Gitea URL cannot contain API paths".to_owned()))?
.extend(path.split('/').filter(|part| !part.is_empty()));
Ok(url)
}
fn decode<T: DeserializeOwned>(
&self,
response: Response,
operation: &str,
) -> Result<T, ApiError> {
let response = check_response(response, operation)?;
response.json().map_err(|error| {
ApiError(format!(
"Gitea returned invalid JSON while {operation}: {error}"
))
})
}
fn get_all<T: DeserializeOwned>(
&self,
path: &str,
operation: &str,
query: &[(&str, &str)],
) -> Result<Vec<T>, ApiError> {
let mut items = Vec::new();
for page in 1..=10_000_u32 {
let page_value = page.to_string();
let mut page_query = query.to_vec();
page_query.extend([("limit", "100"), ("page", page_value.as_str())]);
let response = self
.client
.get(self.endpoint(path)?)
.query(&page_query)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!("cannot reach Gitea while {operation}: {error}"))
})?;
let page_items: Vec<T> = self.decode(response, operation)?;
if page_items.is_empty() {
return Ok(items);
}
items.extend(page_items);
}
Err(ApiError(format!(
"Gitea returned too many pages while {operation}; narrow repository data or check server pagination"
)))
}
}
impl GiteaApi for GiteaClient {
fn list_projects(&self) -> Result<Vec<Project>, ApiError> {
self.get_all(
"projects",
"listing repository projects",
&[("state", "all")],
)
}
fn list_project_columns(&self, project_id: u64) -> Result<Vec<ProjectColumn>, ApiError> {
let response = self
.client
.get(self.endpoint(&format!("projects/{project_id}/columns"))?)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while listing project columns: {error}"
))
})?;
self.decode(response, "listing project columns")
}
fn list_project_column_issues(
&self,
project_id: u64,
column_id: u64,
) -> Result<Vec<Issue>, ApiError> {
self.get_all(
&format!("projects/{project_id}/columns/{column_id}/issues"),
"listing project issues",
&[],
)
}
fn move_project_issue(
&self,
project_id: u64,
issue_id: u64,
payload: &MoveProjectIssuePayload,
) -> Result<(), ApiError> {
let response = self
.client
.post(self.endpoint(&format!("projects/{project_id}/issues/{issue_id}/move"))?)
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while moving project issue: {error}"
))
})?;
check_response(response, "moving project issue")?;
Ok(())
}
fn create_issue(&self, payload: &CreateIssuePayload) -> Result<Issue, ApiError> {
let response = self
.client
.post(self.endpoint("issues")?)
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()
.map_err(|error| {
ApiError(format!("cannot reach Gitea while creating issue: {error}"))
})?;
self.decode(response, "creating issue")
}
fn edit_issue(&self, issue_number: u64, payload: &EditIssuePayload) -> Result<Issue, ApiError> {
let response = self
.client
.patch(self.endpoint(&format!("issues/{issue_number}"))?)
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while editing issue #{issue_number}: {error}"
))
})?;
self.decode(response, &format!("editing issue #{issue_number}"))
}
fn delete_issue(&self, issue_number: u64) -> Result<(), ApiError> {
let response = self
.client
.delete(self.endpoint(&format!("issues/{issue_number}"))?)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while deleting issue #{issue_number}: {error}"
))
})?;
check_response(response, &format!("deleting issue #{issue_number}"))?;
Ok(())
}
fn list_issue_comments(&self, issue_number: u64) -> Result<Vec<Comment>, ApiError> {
self.get_all(
&format!("issues/{issue_number}/comments"),
&format!("listing comments for issue #{issue_number}"),
&[],
)
}
fn create_issue_comment(&self, issue_number: u64, body: String) -> Result<Comment, ApiError> {
let response = self
.client
.post(self.endpoint(&format!("issues/{issue_number}/comments"))?)
.header("Authorization", format!("token {}", self.token))
.json(&CommentPayload { body })
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while creating comment on issue #{issue_number}: {error}"
))
})?;
self.decode(
response,
&format!("creating comment on issue #{issue_number}"),
)
}
fn edit_issue_comment(&self, comment_id: u64, body: String) -> Result<Comment, ApiError> {
let response = self
.client
.patch(self.endpoint(&format!("issues/comments/{comment_id}"))?)
.header("Authorization", format!("token {}", self.token))
.json(&CommentPayload { body })
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while editing comment #{comment_id}: {error}"
))
})?;
self.decode(response, &format!("editing comment #{comment_id}"))
}
fn delete_issue_comment(&self, comment_id: u64) -> Result<(), ApiError> {
let response = self
.client
.delete(self.endpoint(&format!("issues/comments/{comment_id}"))?)
.header("Authorization", format!("token {}", self.token))
.send()
.map_err(|error| {
ApiError(format!(
"cannot reach Gitea while deleting comment #{comment_id}: {error}"
))
})?;
check_response(response, &format!("deleting comment #{comment_id}"))?;
Ok(())
}
}
fn check_response(response: Response, operation: &str) -> Result<Response, ApiError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let guidance = match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
" Check token validity and repository issue permissions."
}
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => {
" Check repository owner/name and whether this Gitea version supports native project APIs."
}
_ => "",
};
Err(ApiError(format!(
"Gitea API failed while {operation} ({status}).{guidance}"
)))
}
fn require_secure_transport(url: &Url) -> Result<(), ApiError> {
if url.scheme() == "https" {
return Ok(());
}
let loopback = url
.host_str()
.and_then(|host| {
host.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
.ok()
})
.is_some_and(|address| address.is_loopback());
if url.scheme() == "http" && loopback {
return Ok(());
}
Err(ApiError(
"Gitea URL must use HTTPS to protect the API token; plain HTTP is allowed only for loopback development"
.to_owned(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc::{self, Receiver};
use std::thread;
fn config(base_url: &str) -> Config {
Config {
base_url: base_url.to_owned(),
token: "secret".to_owned(),
owner: "owner name".to_owned(),
repo: "repo/name".to_owned(),
project: Some("Kanban".to_owned()),
project_id: None,
}
}
fn mock_server(responses: Vec<&'static str>) -> (String, Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let address = listener.local_addr().expect("mock address");
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("accept request");
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer).expect("read request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
let header_end = request
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4);
if let Some(header_end) = header_end {
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length: ")?
.parse::<usize>()
.ok()
})
.unwrap_or(0);
if request.len() >= header_end + content_length {
break;
}
}
}
let _ = sender.send(String::from_utf8(request).expect("UTF-8 request"));
stream
.write_all(response.as_bytes())
.expect("write response");
}
});
(format!("http://{address}"), receiver)
}
#[test]
fn preserves_base_path_and_encodes_repository_segments() {
let client = GiteaClient::new(&config("https://gitea.example/subpath"))
.expect("client should build");
assert_eq!(
client.endpoint("projects").expect("endpoint").as_str(),
"https://gitea.example/subpath/api/v1/repos/owner%20name/repo%2Fname/projects"
);
}
#[test]
fn rejects_remote_plain_http_but_allows_loopback() {
assert!(GiteaClient::new(&config("http://gitea.example")).is_err());
assert!(GiteaClient::new(&config("http://localhost:3000")).is_err());
assert!(GiteaClient::new(&config("http://127.0.0.1:3000")).is_ok());
assert!(GiteaClient::new(&config("http://[::1]:3000")).is_ok());
}
#[test]
fn resolves_exact_project_name_and_rejects_ambiguity() {
let projects = vec![
Project {
id: 1,
title: "Kanban".to_owned(),
is_closed: false,
},
Project {
id: 2,
title: "kanban".to_owned(),
is_closed: false,
},
];
assert_eq!(
resolve_project(&projects, Some("Kanban"), None)
.expect("match")
.id,
1
);
assert!(resolve_project(&projects, Some("Missing"), None).is_err());
assert_eq!(
resolve_project(&projects, None, Some(2)).expect("id").title,
"kanban"
);
}
#[test]
fn lists_projects_with_pagination_and_reports_unsupported_api() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":4,\"title\":\"Kanban\",\"is_closed\":false}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[]",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let projects = client.list_projects().expect("projects");
assert_eq!(projects[0].title, "Kanban");
let first = requests.recv().expect("first request");
let second = requests.recv().expect("second request");
assert!(first.starts_with("GET /api/v1/repos/owner%20name/repo%2Fname/projects?"));
assert!(first.contains("state=all"));
assert!(first.contains("page=1"));
assert!(second.contains("page=2"));
assert!(first.contains("authorization: token secret"));
let (base_url, _) = mock_server(vec![
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"message\":\"projects unavailable secret\"}",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let error = client
.list_project_columns(4)
.expect_err("unsupported API must fail");
assert!(error.to_string().contains("404 Not Found"));
assert!(!error.to_string().contains("secret"));
}
#[test]
fn sends_global_issue_move_payload() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
client
.move_project_issue(
4,
99,
&MoveProjectIssuePayload {
column_id: 12,
sorting: Some(3),
},
)
.expect("move");
let request = requests.recv().expect("request");
assert!(request.starts_with(
"POST /api/v1/repos/owner%20name/repo%2Fname/projects/4/issues/99/move HTTP/1.1"
));
assert!(request.ends_with("{\"column_id\":12,\"sorting\":3}"));
}
#[test]
fn paginates_native_column_issues() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":99,\"number\":7,\"title\":\"Fix\"}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[]",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let issues = client
.list_project_column_issues(4, 12)
.expect("column issues");
assert_eq!(issues[0].id, 99);
assert!(requests.recv().expect("page one").contains("page=1"));
assert!(requests.recv().expect("page two").contains("page=2"));
}
#[test]
fn creates_edits_and_deletes_issues() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"New\"}",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"Updated\"}",
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let created = client
.create_issue(&CreateIssuePayload {
title: "New".to_owned(),
body: "Details".to_owned(),
projects: vec![4],
})
.expect("create");
assert_eq!(created.number, 7);
let edited = client
.edit_issue(
7,
&EditIssuePayload {
title: "Updated".to_owned(),
body: "Changed".to_owned(),
},
)
.expect("edit");
assert_eq!(edited.title, "Updated");
client.delete_issue(7).expect("delete");
assert!(requests
.recv()
.expect("create request")
.ends_with("{\"title\":\"New\",\"body\":\"Details\",\"projects\":[4]}"));
assert!(requests
.recv()
.expect("edit request")
.starts_with("PATCH /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1"));
assert!(requests
.recv()
.expect("delete request")
.starts_with("DELETE /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1"));
}
}
+502
View File
@@ -0,0 +1,502 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::{
ColumnSpec, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectColumn,
};
#[derive(Clone, Debug)]
pub struct Column {
pub spec: ColumnSpec,
pub cards: Vec<Issue>,
}
pub struct App {
pub board_title: String,
pub columns: Vec<Column>,
pub focused_column: usize,
pub focused_cards: Vec<usize>,
pub status: String,
pub show_help: bool,
pub editor: Option<EditorState>,
project_id: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EditorMode {
Create { project_id: u64 },
Edit { issue_number: u64 },
Delete { issue_number: u64, title: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditorField {
Title,
Body,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorState {
pub mode: EditorMode,
pub field: EditorField,
pub title: String,
pub body: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IssueAction {
Create {
project_id: u64,
title: String,
body: String,
},
Edit {
issue_number: u64,
payload: EditIssuePayload,
},
Delete {
issue_number: u64,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum MoveAction {
Project {
issue_id: u64,
project_id: u64,
payload: MoveProjectIssuePayload,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct MoveRequest {
pub source: usize,
pub target: usize,
pub action: MoveAction,
}
impl App {
pub fn new_project(
project: Project,
project_columns: Vec<ProjectColumn>,
issues_by_column: Vec<Vec<Issue>>,
) -> Result<Self, String> {
if project_columns.is_empty() {
return Err(format!("project '{}' has no columns", project.title));
}
if project_columns.len() != issues_by_column.len() {
return Err("project columns and issue lists do not match".to_owned());
}
let columns = project_columns
.into_iter()
.zip(issues_by_column)
.map(|(column, cards)| Column {
spec: ColumnSpec {
id: column.id,
title: column.title,
},
cards,
})
.collect::<Vec<_>>();
let focused_column = columns
.iter()
.position(|column| !column.cards.is_empty())
.unwrap_or(0);
let focused_cards = vec![0; columns.len()];
Ok(Self {
board_title: format!("{} (project {})", project.title, project.id),
columns,
focused_column,
focused_cards,
status: "Ready".to_owned(),
show_help: false,
editor: None,
project_id: project.id,
})
}
pub fn focus_left(&mut self) {
self.focused_column = self.focused_column.saturating_sub(1);
}
pub fn focus_right(&mut self) {
self.focused_column = (self.focused_column + 1).min(self.columns.len() - 1);
}
pub fn focus_up(&mut self) {
let selected = &mut self.focused_cards[self.focused_column];
*selected = selected.saturating_sub(1);
}
pub fn focus_down(&mut self) {
let column = &self.columns[self.focused_column];
if !column.cards.is_empty() {
let selected = &mut self.focused_cards[self.focused_column];
*selected = (*selected + 1).min(column.cards.len() - 1);
}
}
pub fn focused_card(&self) -> Option<&Issue> {
self.columns[self.focused_column]
.cards
.get(self.focused_cards[self.focused_column])
}
pub fn prepare_move(&self, offset: isize) -> Option<MoveRequest> {
let target = self.focused_column.checked_add_signed(offset)?;
if target >= self.columns.len() {
return None;
}
let issue = self.focused_card()?;
let action = MoveAction::Project {
issue_id: issue.id,
project_id: self.project_id,
payload: MoveProjectIssuePayload {
column_id: self.columns[target].spec.id,
sorting: None,
},
};
Some(MoveRequest {
source: self.focused_column,
target,
action,
})
}
pub fn apply_move(&mut self, request: MoveRequest) {
let selected = self.focused_cards[request.source];
let issue = self.columns[request.source].cards.remove(selected);
let issue_number = issue.number;
self.columns[request.target].cards.push(issue);
self.focused_cards[request.source] =
selected.min(self.columns[request.source].cards.len().saturating_sub(1));
self.focused_cards[request.target] = self.columns[request.target].cards.len() - 1;
self.focused_column = request.target;
self.status = format!("Moved issue #{issue_number}");
}
pub fn begin_create(&mut self) {
self.editor = Some(EditorState {
mode: EditorMode::Create {
project_id: self.project_id,
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
});
}
pub fn begin_edit(&mut self) {
let Some(issue) = self.focused_card().cloned() else {
self.status = "No issue selected".to_owned();
return;
};
self.editor = Some(EditorState {
mode: EditorMode::Edit {
issue_number: issue.number,
},
field: EditorField::Title,
title: issue.title,
body: issue.body.unwrap_or_default(),
});
}
pub fn begin_delete(&mut self) {
let Some(issue) = self.focused_card() else {
self.status = "No issue selected".to_owned();
return;
};
self.editor = Some(EditorState {
mode: EditorMode::Delete {
issue_number: issue.number,
title: issue.title.clone(),
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
});
}
pub fn handle_editor_key(&mut self, key: KeyEvent) -> Option<IssueAction> {
let mut editor = self.editor.take()?;
if matches!(editor.mode, EditorMode::Delete { .. }) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let EditorMode::Delete { issue_number, .. } = editor.mode {
return Some(IssueAction::Delete { issue_number });
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.status = "Delete cancelled".to_owned();
}
_ => {
self.editor = Some(editor);
}
}
return None;
}
if key.code == KeyCode::Esc {
self.status = "Edit cancelled".to_owned();
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
return self.submit_editor(editor);
}
match key.code {
KeyCode::Tab => {
editor.field = match editor.field {
EditorField::Title => EditorField::Body,
EditorField::Body => EditorField::Title,
};
}
KeyCode::Enter if editor.field == EditorField::Title => {
editor.field = EditorField::Body;
}
KeyCode::Enter => editor.body.push('\n'),
KeyCode::Backspace => match editor.field {
EditorField::Title => {
editor.title.pop();
}
EditorField::Body => {
editor.body.pop();
}
},
KeyCode::Char(character) if !character.is_control() => match editor.field {
EditorField::Title => editor.title.push(character),
EditorField::Body => editor.body.push(character),
},
_ => {}
}
self.editor = Some(editor);
None
}
pub fn restore_issue_action(&mut self, action: IssueAction) {
self.editor = Some(match action {
IssueAction::Create {
project_id,
title,
body,
} => EditorState {
mode: EditorMode::Create { project_id },
field: EditorField::Body,
title,
body,
},
IssueAction::Edit {
issue_number,
payload,
} => EditorState {
mode: EditorMode::Edit { issue_number },
field: EditorField::Body,
title: payload.title,
body: payload.body,
},
IssueAction::Delete { issue_number } => EditorState {
mode: EditorMode::Delete {
issue_number,
title: self
.focused_card()
.map(|issue| issue.title.clone())
.unwrap_or_default(),
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
},
});
}
fn submit_editor(&mut self, editor: EditorState) -> Option<IssueAction> {
if editor.title.trim().is_empty() {
self.status = "Title cannot be empty".to_owned();
self.editor = Some(editor);
return None;
}
match editor.mode {
EditorMode::Create { project_id } => Some(IssueAction::Create {
project_id,
title: editor.title,
body: editor.body,
}),
EditorMode::Edit { issue_number } => Some(IssueAction::Edit {
issue_number,
payload: EditIssuePayload {
title: editor.title,
body: editor.body,
},
}),
EditorMode::Delete { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::*;
fn app() -> App {
App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![
ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
},
ProjectColumn {
id: 20,
title: "Done".to_owned(),
color: String::new(),
sorting: 1,
},
],
vec![
vec![
Issue {
id: 1,
number: 1,
title: "First".to_owned(),
body: None,
},
Issue {
id: 2,
number: 2,
title: "Second".to_owned(),
body: None,
},
],
Vec::new(),
],
)
.expect("board builds")
}
#[test]
fn navigation_stays_within_board() {
let mut app = app();
app.focus_left();
app.focus_up();
assert_eq!(app.focused_column, 0);
assert_eq!(app.focused_cards[0], 0);
app.focus_down();
app.focus_down();
app.focus_right();
app.focus_right();
assert_eq!(app.focused_cards[0], 1);
assert_eq!(app.focused_column, 1);
}
#[test]
fn cannot_move_past_board_edge() {
let app = app();
assert!(app.prepare_move(-1).is_none());
}
#[test]
fn empty_board_returns_actionable_error() {
let result = App::new_project(
Project {
id: 1,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![ProjectColumn {
id: 1,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
}],
vec![Vec::new()],
);
assert!(result.is_ok());
}
#[test]
fn native_move_uses_global_issue_and_column_ids() {
let mut app = App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![
ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
},
ProjectColumn {
id: 20,
title: "Done".to_owned(),
color: String::new(),
sorting: 1,
},
],
vec![
vec![Issue {
id: 99,
number: 7,
title: "Fix".to_owned(),
body: None,
}],
Vec::new(),
],
)
.expect("board");
let request = app.prepare_move(1).expect("move");
assert!(matches!(
request.action,
MoveAction::Project { issue_id: 99, ref payload, .. } if payload.column_id == 20
));
app.apply_move(request);
assert_eq!(app.columns[1].cards[0].id, 99);
}
#[test]
fn native_editor_creates_project_issue_action() {
let mut app = App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
}],
vec![Vec::new()],
)
.expect("board");
app.begin_create();
for character in "New issue".chars() {
app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
}
app.handle_editor_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
for character in "Details".chars() {
app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
}
let action =
app.handle_editor_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(matches!(
action,
Some(IssueAction::Create {
project_id: 8,
title,
body,
}) if title == "New issue" && body == "Details"
));
assert!(app.editor.is_none());
}
}
@@ -0,0 +1,251 @@
use std::error::Error;
use clap::{Parser, Subcommand};
use gitea_kanban_tui::api::{load_project_board, GiteaClient};
use gitea_kanban_tui::config::{Config, SharedArgs};
use gitea_kanban_tui::model::EditIssuePayload;
use gitea_kanban_tui::operations;
use gitea_kanban_tui::text::sanitize_terminal_text;
#[derive(Debug, Parser)]
#[command(
version,
about = "Control native Gitea project boards from the command line"
)]
struct Cli {
#[command(flatten)]
shared: SharedArgs,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Print project columns and their issues.
Board,
/// Create an issue assigned to selected project.
Create {
#[arg(long)]
title: String,
#[arg(long, default_value = "")]
body: String,
},
/// Replace issue title and body.
Edit {
issue: u64,
#[arg(long)]
title: String,
#[arg(long)]
body: String,
},
/// Delete an issue. Requires repository admin permission.
Delete {
issue: u64,
/// Confirm irreversible deletion.
#[arg(long)]
yes: bool,
},
/// Move an issue to a project column.
Move {
issue_id: u64,
#[arg(long)]
column_id: u64,
#[arg(long)]
sorting: Option<u64>,
},
/// Manage comments on an issue in selected project.
Comment {
#[command(subcommand)]
command: CommentCommand,
},
}
#[derive(Debug, Subcommand)]
enum CommentCommand {
/// List comments for an issue.
List { issue: u64 },
/// Add a comment to an issue.
Add {
issue: u64,
#[arg(long)]
body: String,
},
/// Replace a comment body.
Edit {
issue: u64,
comment_id: u64,
#[arg(long)]
body: String,
},
/// Delete a comment.
Delete {
issue: u64,
comment_id: u64,
#[arg(long)]
yes: bool,
},
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {}", sanitize_terminal_text(&error.to_string()));
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse();
let config = Config::from_shared_args_with(cli.shared, |name| std::env::var(name).ok())?;
let client = GiteaClient::new(&config)?;
match cli.command {
Command::Board => {
let board = load_project_board(&client, &config)?;
println!(
"{} (project {})",
sanitize_terminal_text(&board.project.title),
board.project.id
);
for (column, issues) in board.columns.iter().zip(board.issues_by_column) {
println!(
"\n{} ({})",
sanitize_terminal_text(&column.title),
column.id
);
for issue in issues {
println!(
" #{} {}",
issue.number,
sanitize_terminal_text(&issue.title)
);
}
}
}
Command::Create { title, body } => {
let board = load_project_board(&client, &config)?;
let issue = operations::create_project_issue(&client, board.project.id, title, body)?;
println!("created issue #{}", issue.number);
}
Command::Edit { issue, title, body } => {
let board = load_project_board(&client, &config)?;
if !board
.issues_by_column
.iter()
.flatten()
.any(|candidate| candidate.number == issue)
{
return Err(format!("issue #{issue} is not assigned to selected project").into());
}
operations::edit_issue(&client, issue, &EditIssuePayload { title, body })?;
println!("updated issue #{issue}");
}
Command::Delete { issue, yes } => {
if !yes {
return Err("deletion requires --yes".into());
}
let board = load_project_board(&client, &config)?;
if !board
.issues_by_column
.iter()
.flatten()
.any(|candidate| candidate.number == issue)
{
return Err(format!("issue #{issue} is not assigned to selected project").into());
}
operations::delete_issue(&client, issue)?;
println!("deleted issue #{issue}");
}
Command::Move {
issue_id,
column_id,
sorting,
} => {
let board = load_project_board(&client, &config)?;
operations::move_project_issue(
&client,
board.project.id,
issue_id,
&gitea_kanban_tui::model::MoveProjectIssuePayload { column_id, sorting },
)?;
println!("moved issue {issue_id} to column {column_id}");
}
Command::Comment { command } => match command {
CommentCommand::List { issue } => {
for comment in project_issue_comments(&client, &config, issue)? {
let author = comment
.user
.as_ref()
.map(|user| user.login.as_str())
.unwrap_or("unknown");
println!(
"#{} {}\n{}",
comment.id,
sanitize_terminal_text(author),
sanitize_terminal_text(&comment.body)
);
}
}
CommentCommand::Add { issue, body } => {
project_issue_comments(&client, &config, issue)?;
let comment = operations::create_issue_comment(&client, issue, body)?;
println!("created comment #{} on issue #{}", comment.id, issue);
}
CommentCommand::Edit {
issue,
comment_id,
body,
} => {
ensure_comment(&client, &config, issue, comment_id)?;
operations::edit_issue_comment(&client, comment_id, body)?;
println!("updated comment #{comment_id}");
}
CommentCommand::Delete {
issue,
comment_id,
yes,
} => {
if !yes {
return Err("comment deletion requires --yes".into());
}
ensure_comment(&client, &config, issue, comment_id)?;
operations::delete_issue_comment(&client, comment_id)?;
println!("deleted comment #{comment_id}");
}
},
}
Ok(())
}
fn project_issue_comments(
client: &GiteaClient,
config: &Config,
issue: u64,
) -> Result<Vec<gitea_kanban_tui::model::Comment>, Box<dyn Error>> {
let board = load_project_board(client, config)?;
if !board
.issues_by_column
.iter()
.flatten()
.any(|candidate| candidate.number == issue)
{
return Err(format!("issue #{issue} is not assigned to selected project").into());
}
Ok(operations::list_issue_comments(client, issue)?)
}
fn ensure_comment(
client: &GiteaClient,
config: &Config,
issue: u64,
comment_id: u64,
) -> Result<(), Box<dyn Error>> {
if !project_issue_comments(client, config, issue)?
.iter()
.any(|comment| comment.id == comment_id)
{
return Err(format!("comment #{comment_id} is not attached to issue #{issue}").into());
}
Ok(())
}
+303
View File
@@ -0,0 +1,303 @@
use std::env;
use std::fmt;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use clap::{Args as ClapArgs, Parser};
#[derive(Clone, Debug, ClapArgs)]
pub struct SharedArgs {
/// Gitea base URL; falls back to GITEA_URL
#[arg(long)]
pub url: Option<String>,
/// File containing API token; falls back to GITEA_TOKEN_FILE
#[arg(long, value_name = "PATH")]
pub token_file: Option<PathBuf>,
/// Exact native project name
#[arg(long)]
pub project: Option<String>,
/// Native project ID
#[arg(long)]
pub project_id: Option<u64>,
/// Repository owner; falls back to GITEA_OWNER
pub owner: Option<String>,
/// Repository name; falls back to GITEA_REPO
pub repo: Option<String>,
}
#[derive(Debug, Parser)]
#[command(
name = "gitea-kanban-tui",
version,
about = "Browse and move Gitea issues using native projects"
)]
pub struct Args {
#[command(flatten)]
pub shared: SharedArgs,
}
#[derive(Debug)]
pub struct Config {
pub base_url: String,
pub token: String,
pub owner: String,
pub repo: String,
pub project: Option<String>,
pub project_id: Option<u64>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ConfigError(String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
Self::from_args_with(Args::parse(), |name| env::var(name).ok())
}
pub fn from_args_with<F>(args: Args, env_var: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
Self::from_shared_args_with(args.shared, env_var)
}
pub fn from_shared_args_with<F>(args: SharedArgs, env_var: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
let base_url = required(
args.url.or_else(|| env_var("GITEA_URL")),
"Gitea URL",
"--url or GITEA_URL",
)?;
let owner = required(
args.owner.or_else(|| env_var("GITEA_OWNER")),
"repository owner",
"OWNER argument or GITEA_OWNER",
)?;
let repo = required(
args.repo.or_else(|| env_var("GITEA_REPO")),
"repository name",
"REPO argument or GITEA_REPO",
)?;
let project = args
.project
.or_else(|| env_var("GITEA_PROJECT"))
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
let project_id = args
.project_id
.or_else(|| env_var("GITEA_PROJECT_ID").and_then(|value| value.parse::<u64>().ok()));
if project.is_some() == project_id.is_some() {
return Err(ConfigError(
"projects mode requires exactly one of --project/GITEA_PROJECT or --project-id/GITEA_PROJECT_ID"
.to_owned(),
));
}
let token = if let Some(path) = args.token_file {
read_token_file(path)?
} else if let Some(path) = env_var("GITEA_TOKEN_FILE") {
read_token_file(PathBuf::from(path))?
} else if let Some(token) = env_var("GITEA_TOKEN") {
clean_token(token, "GITEA_TOKEN")?
} else {
return Err(ConfigError(
"missing Gitea token; use --token-file, GITEA_TOKEN, or GITEA_TOKEN_FILE"
.to_owned(),
));
};
Ok(Self {
base_url: base_url.trim_end_matches('/').to_owned(),
token,
owner,
repo,
project,
project_id,
})
}
}
fn required(value: Option<String>, name: &str, source: &str) -> Result<String, ConfigError> {
match value.map(|value| value.trim().to_owned()) {
Some(value) if !value.is_empty() => Ok(value),
_ => Err(ConfigError(format!("missing {name}; use {source}"))),
}
}
fn clean_token(token: String, source: &str) -> Result<String, ConfigError> {
let token = token.trim().to_owned();
if token.is_empty() {
Err(ConfigError(format!("{source} contains an empty token")))
} else {
Ok(token)
}
}
fn read_token_file(path: PathBuf) -> Result<String, ConfigError> {
let metadata = fs::symlink_metadata(&path).map_err(|error| {
ConfigError(format!(
"cannot inspect token file {}: {error}",
path.display()
))
})?;
if !metadata.is_file() {
return Err(ConfigError(format!(
"token path {} is not a regular file",
path.display()
)));
}
#[cfg(unix)]
if metadata.permissions().mode() & 0o077 != 0 {
return Err(ConfigError(format!(
"token file {} must not be group- or world-readable",
path.display()
)));
}
let token = fs::read_to_string(&path).map_err(|error| {
ConfigError(format!(
"cannot read token file {}: {error}",
path.display()
))
})?;
clean_token(token, &format!("token file {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_env(_: &str) -> Option<String> {
None
}
fn token_env(name: &str) -> Option<String> {
(name == "GITEA_TOKEN").then(|| "secret".to_owned())
}
#[test]
fn parses_explicit_configuration() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example/",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let config = Config::from_args_with(args, token_env).expect("config is valid");
assert_eq!(config.base_url, "https://gitea.example");
assert_eq!(config.owner, "owner");
assert_eq!(config.repo, "repo");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.token, "secret");
}
#[test]
fn parses_native_project_by_name() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let config = Config::from_args_with(args, token_env).expect("config is valid");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.project_id, None);
}
#[test]
fn requires_project_selector() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"owner",
"repo",
])
.expect("arguments parse");
let error =
Config::from_args_with(args, token_env).expect_err("project selector is required");
assert!(error.to_string().contains("--project"));
}
#[test]
fn native_project_selector_is_unambiguous() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"owner",
"repo",
])
.expect("arguments parse");
let error = Config::from_args_with(args, token_env).expect_err("selector is required");
assert!(error.to_string().contains("exactly one"));
}
#[test]
fn falls_back_to_environment() {
let args = Args::try_parse_from(["gitea-kanban-tui"]).expect("arguments parse");
let config = Config::from_args_with(args, |name| {
match name {
"GITEA_URL" => Some("https://gitea.example"),
"GITEA_TOKEN" => Some("secret"),
"GITEA_PROJECT" => Some("Kanban"),
"GITEA_OWNER" => Some("owner"),
"GITEA_REPO" => Some("repo"),
_ => None,
}
.map(str::to_owned)
})
.expect("config is valid");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.owner, "owner");
}
#[test]
fn reports_missing_token_without_exposing_values() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let error = match Config::from_args_with(args, empty_env) {
Ok(_) => panic!("token should be required"),
Err(error) => error,
};
assert!(error.to_string().contains("GITEA_TOKEN_FILE"));
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod api;
pub mod app;
pub mod config;
pub mod model;
pub mod operations;
pub mod terminal;
pub mod text;
pub mod ui;
+125
View File
@@ -0,0 +1,125 @@
use std::error::Error;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use gitea_kanban_tui::api::{load_project_board, GiteaApi, GiteaClient};
use gitea_kanban_tui::app::{App, IssueAction, MoveAction};
use gitea_kanban_tui::config::Config;
use gitea_kanban_tui::operations;
use gitea_kanban_tui::terminal::TerminalGuard;
use gitea_kanban_tui::text::sanitize_terminal_text;
use gitea_kanban_tui::ui;
fn main() {
if let Err(error) = run() {
eprintln!("error: {}", sanitize_terminal_text(&error.to_string()));
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn Error>> {
let config = Config::load()?;
let client = GiteaClient::new(&config)?;
let mut app = load_board(&client, &config)?;
let repository = format!("{}/{}", config.owner, config.repo);
let mut terminal = TerminalGuard::enter()?;
loop {
terminal
.terminal()
.draw(|frame| ui::draw(frame, &app, &repository))?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if app.editor.is_some() {
if let Some(action) = app.handle_editor_key(key) {
issue_action(&client, &config, &mut app, action);
}
continue;
}
match key.code {
KeyCode::Char('q') => break,
KeyCode::Left | KeyCode::Char('h') => app.focus_left(),
KeyCode::Right | KeyCode::Char('l') => app.focus_right(),
KeyCode::Up | KeyCode::Char('k') => app.focus_up(),
KeyCode::Down | KeyCode::Char('j') => app.focus_down(),
KeyCode::Char('H') => move_card(&client, &mut app, -1),
KeyCode::Char('L') => move_card(&client, &mut app, 1),
KeyCode::Char('n') => app.begin_create(),
KeyCode::Char('e') => app.begin_edit(),
KeyCode::Char('d') => app.begin_delete(),
KeyCode::Char('r') => match load_board(&client, &config) {
Ok(board) => app = board,
Err(error) => app.status = format!("Refresh failed: {error}"),
},
KeyCode::Char('?') => app.show_help = !app.show_help,
_ => {}
}
}
Ok(())
}
fn issue_action(client: &impl GiteaApi, config: &Config, app: &mut App, action: IssueAction) {
let retry_action = action.clone();
let message = match action {
IssueAction::Create {
project_id,
title,
body,
} => operations::create_project_issue(client, project_id, title, body)
.map(|issue| format!("Created issue #{}", issue.number)),
IssueAction::Edit {
issue_number,
payload,
} => operations::edit_issue(client, issue_number, &payload)
.map(|_| format!("Updated issue #{issue_number}")),
IssueAction::Delete { issue_number } => operations::delete_issue(client, issue_number)
.map(|_| format!("Deleted issue #{issue_number}")),
};
match message {
Ok(message) => match load_board(client, config) {
Ok(mut board) => {
board.status = message;
*app = board;
}
Err(error) => app.status = format!("Saved, refresh failed: {error}"),
},
Err(error) => {
app.restore_issue_action(retry_action);
app.status = format!("Issue operation failed: {error}");
}
}
}
fn load_board(client: &impl GiteaApi, config: &Config) -> Result<App, Box<dyn Error>> {
let board = load_project_board(client, config)?;
App::new_project(board.project, board.columns, board.issues_by_column)
.map_err(|error| error.into())
}
fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) {
let Some(request) = app.prepare_move(offset) else {
app.status = "Cannot move card beyond board edge".to_owned();
return;
};
let result = match &request.action {
MoveAction::Project {
issue_id,
project_id,
payload,
} => operations::move_project_issue(client, *project_id, *issue_id, payload),
};
match result {
Ok(()) => app.apply_move(request),
Err(error) => app.status = format!("Move failed: {error}"),
}
}
+158
View File
@@ -0,0 +1,158 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Issue {
pub id: u64,
pub number: u64,
pub title: String,
#[serde(default)]
pub body: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Comment {
pub id: u64,
pub body: String,
#[serde(default)]
pub user: Option<CommentUser>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct CommentUser {
pub login: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct CommentPayload {
pub body: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct CreateIssuePayload {
pub title: String,
pub body: String,
pub projects: Vec<u64>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct EditIssuePayload {
pub title: String,
pub body: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ColumnSpec {
pub id: u64,
pub title: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct Project {
pub id: u64,
pub title: String,
#[serde(default)]
pub is_closed: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct ProjectColumn {
pub id: u64,
pub title: String,
#[serde(default)]
pub color: String,
#[serde(default)]
pub sorting: i8,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectBoard {
pub project: Project,
pub columns: Vec<ProjectColumn>,
pub issues_by_column: Vec<Vec<Issue>>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct MoveProjectIssuePayload {
pub column_id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub sorting: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_native_project_data_and_move_payload() {
let project: Project = serde_json::from_value(serde_json::json!({
"id": 4,
"title": "Kanban",
"is_closed": false
}))
.expect("project JSON");
let column: ProjectColumn = serde_json::from_value(serde_json::json!({
"id": 9,
"title": "Doing",
"sorting": 1
}))
.expect("column JSON");
let issue: Issue = serde_json::from_value(serde_json::json!({
"id": 70,
"number": 7,
"title": "Fix it"
}))
.expect("issue JSON");
let payload = MoveProjectIssuePayload {
column_id: column.id,
sorting: Some(3),
};
assert_eq!(project.title, "Kanban");
assert_eq!(issue.id, 70);
assert_eq!(
serde_json::to_value(payload).expect("move payload"),
serde_json::json!({"column_id": 9, "sorting": 3})
);
}
#[test]
fn serializes_issue_create_and_edit_payloads() {
let create = CreateIssuePayload {
title: "New issue".to_owned(),
body: "Details".to_owned(),
projects: vec![4],
};
let edit = EditIssuePayload {
title: "Updated".to_owned(),
body: "Changed".to_owned(),
};
assert_eq!(
serde_json::to_value(create).expect("create payload"),
serde_json::json!({"title": "New issue", "body": "Details", "projects": [4]})
);
assert_eq!(
serde_json::to_value(edit).expect("edit payload"),
serde_json::json!({"title": "Updated", "body": "Changed"})
);
}
#[test]
fn serializes_and_deserializes_comment_data() {
let comment: Comment = serde_json::from_value(serde_json::json!({
"id": 9001,
"body": "Investigating",
"user": {"login": "alice"}
}))
.expect("comment JSON");
let payload = CommentPayload {
body: "Resolved".to_owned(),
};
assert_eq!(comment.id, 9001);
assert_eq!(comment.user.expect("comment user").login, "alice");
assert_eq!(
serde_json::to_value(payload).expect("comment payload"),
serde_json::json!({"body": "Resolved"})
);
}
}
@@ -0,0 +1,63 @@
use crate::api::{ApiError, GiteaApi};
use crate::model::{Comment, CreateIssuePayload, EditIssuePayload, Issue, MoveProjectIssuePayload};
pub fn create_project_issue(
client: &impl GiteaApi,
project_id: u64,
title: String,
body: String,
) -> Result<Issue, ApiError> {
client.create_issue(&CreateIssuePayload {
title,
body,
projects: vec![project_id],
})
}
pub fn edit_issue(
client: &impl GiteaApi,
issue_number: u64,
payload: &EditIssuePayload,
) -> Result<Issue, ApiError> {
client.edit_issue(issue_number, payload)
}
pub fn delete_issue(client: &impl GiteaApi, issue_number: u64) -> Result<(), ApiError> {
client.delete_issue(issue_number)
}
pub fn move_project_issue(
client: &impl GiteaApi,
project_id: u64,
issue_id: u64,
payload: &MoveProjectIssuePayload,
) -> Result<(), ApiError> {
client.move_project_issue(project_id, issue_id, payload)
}
pub fn list_issue_comments(
client: &impl GiteaApi,
issue_number: u64,
) -> Result<Vec<Comment>, ApiError> {
client.list_issue_comments(issue_number)
}
pub fn create_issue_comment(
client: &impl GiteaApi,
issue_number: u64,
body: String,
) -> Result<Comment, ApiError> {
client.create_issue_comment(issue_number, body)
}
pub fn edit_issue_comment(
client: &impl GiteaApi,
comment_id: u64,
body: String,
) -> Result<Comment, ApiError> {
client.edit_issue_comment(comment_id, body)
}
pub fn delete_issue_comment(client: &impl GiteaApi, comment_id: u64) -> Result<(), ApiError> {
client.delete_issue_comment(comment_id)
}
+45
View File
@@ -0,0 +1,45 @@
use std::io::{self, Stdout};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
pub struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
pub fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(error) = execute!(stdout, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(error);
}
let backend = CrosstermBackend::new(stdout);
match Terminal::new(backend) {
Ok(terminal) => Ok(Self { terminal }),
Err(error) => {
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen);
let _ = disable_raw_mode();
Err(error)
}
}
}
pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
&mut self.terminal
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
+46
View File
@@ -0,0 +1,46 @@
pub fn sanitize_terminal_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect()
}
pub fn sanitize_editor_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character == '\n' || !character.is_control() {
character
} else {
' '
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_terminal_control_characters() {
assert_eq!(
sanitize_terminal_text("safe\u{1b}[31m\ntext"),
"safe [31m text"
);
}
#[test]
fn editor_sanitizer_preserves_newlines() {
assert_eq!(
sanitize_editor_text("first\nsecond\u{1b}[31m"),
"first\nsecond [31m"
);
}
}
+208
View File
@@ -0,0 +1,208 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::{App, Column, EditorField, EditorMode, EditorState};
use crate::text::{sanitize_editor_text, sanitize_terminal_text};
pub fn draw(frame: &mut Frame<'_>, app: &App, repository: &str) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(6),
Constraint::Length(4),
Constraint::Length(if app.show_help { 3 } else { 1 }),
])
.split(frame.area());
frame.render_widget(
Paragraph::new(format!(
"Gitea Kanban — {} — {}",
sanitize_terminal_text(repository),
sanitize_terminal_text(&app.board_title)
))
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
areas[0],
);
draw_columns(frame, app, areas[1]);
draw_detail(frame, app, areas[2]);
let help = if app.show_help {
format!(
"{}\n←/h →/l: column ↑/k ↓/j: card H/L: move n: new e: edit d: delete r: refresh ?: help q: quit",
sanitize_terminal_text(&app.status)
)
} else {
format!(
"{} | ?: help q: quit",
sanitize_terminal_text(&app.status)
)
};
frame.render_widget(Paragraph::new(help).wrap(Wrap { trim: true }), areas[3]);
if let Some(editor) = &app.editor {
draw_editor(frame, editor);
}
}
fn draw_editor(frame: &mut Frame<'_>, editor: &EditorState) {
let area = centered_rect(frame.area(), 80, 45);
frame.render_widget(Clear, area);
let (title, content) = match &editor.mode {
EditorMode::Delete {
issue_number,
title,
} => (
"Delete issue".to_owned(),
format!(
"Delete issue #{issue_number} — {}?\n\n[y] confirm [n/Esc] cancel",
sanitize_terminal_text(title)
),
),
EditorMode::Create { .. } => (
"New issue".to_owned(),
editor_content(editor, "Create issue"),
),
EditorMode::Edit { issue_number } => (
format!("Edit issue #{issue_number}"),
editor_content(editor, "Edit issue"),
),
};
frame.render_widget(
Paragraph::new(content)
.block(
Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL),
)
.wrap(Wrap { trim: false }),
area,
);
}
fn editor_content(editor: &EditorState, action: &str) -> String {
let title_marker = if editor.field == EditorField::Title {
"▶ "
} else {
" "
};
let body_marker = if editor.field == EditorField::Body {
"▶ "
} else {
" "
};
format!(
"{title_marker}Title: {}\n{body_marker}Body: {}\n\nTab: switch field Enter: title → body Ctrl-S: save Esc: cancel\n{action}",
sanitize_terminal_text(&editor.title),
sanitize_editor_text(&editor.body)
)
}
fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}
fn draw_columns(frame: &mut Frame<'_>, app: &App, area: Rect) {
let widths = vec![Constraint::Ratio(1, app.columns.len() as u32); app.columns.len()];
let areas = Layout::default()
.direction(Direction::Horizontal)
.constraints(widths)
.split(area);
for (index, column) in app.columns.iter().enumerate() {
draw_column(frame, app, column, index, areas[index]);
}
}
fn draw_column(frame: &mut Frame<'_>, app: &App, column: &Column, index: usize, area: Rect) {
let focused = index == app.focused_column;
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let items: Vec<ListItem<'_>> = column
.cards
.iter()
.map(|issue| {
ListItem::new(Line::from(vec![
Span::styled(
format!("#{} ", issue.number),
Style::default().fg(Color::DarkGray),
),
Span::raw(sanitize_terminal_text(&issue.title)),
]))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!(
" {} ({}) ",
sanitize_terminal_text(&column.spec.title),
column.cards.len()
))
.borders(Borders::ALL)
.border_style(border_style),
)
.highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
if focused && !column.cards.is_empty() {
state.select(Some(app.focused_cards[index]));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
let text = app
.focused_card()
.map(|issue| {
let body = sanitize_terminal_text(
&issue
.body
.as_deref()
.unwrap_or("No description")
.replace('\n', " "),
);
format!(
"#{} {}\n{}",
issue.number,
sanitize_terminal_text(&issue.title),
body
)
})
.unwrap_or_else(|| "No card in focused column".to_owned());
frame.render_widget(
Paragraph::new(text)
.block(Block::default().title(" Detail ").borders(Borders::ALL))
.wrap(Wrap { trim: true }),
area,
);
}
+9 -51
View File
@@ -194,6 +194,7 @@ gcr_alloc_deferred() {
reused="$(gcr_record_get "$job_id" "$attempt")"
vm_id="$(gcr_record_field "$reused" vm_id)"
if gcr_vm_runner_service "$vm_id" start \
&& gcr_vm_runner_service "$vm_id" health \
&& gcr_gitea_runner_disabled "$repo" "$(gcr_record_field "$reused" vm_name)" false; then
reused="$(gcr_record_get "$job_id" "$attempt")"
reused="$(printf '%s' "$reused" | jq -c '.bootstrapped = true | del(.reused_vm)')"
@@ -410,6 +411,7 @@ gcr_bootstrap_pending() {
if [ "$(gcr_record_field "$rec" reused_vm)" = "true" ]; then
if gcr_vm_runner_service "$vm_id" start \
&& gcr_vm_runner_service "$vm_id" health \
&& gcr_gitea_runner_disabled "$repo" "$runner_name" false; then
rec="$(printf '%s' "$rec" | jq -c '.bootstrapped = true | del(.reused_vm)')"
gcr_record_put "$job_id" "$attempt" "$rec"
@@ -506,58 +508,14 @@ gcr_reap_finished_jobs() {
pending_vm|vm_active) ;;
*) gcr_lock_release "$key"; continue ;;
esac
vm_id="$(gcr_record_field "$rec" vm_id)"
if [ "$state" = "completed:success" ] \
&& idle_rec="$(gcr_record_idle_json "$rec")"; then
if ! gcr_lock_acquire idle-pool; then
gcr_lock_release "$key"
continue
fi
runner_name="$(gcr_record_field "$rec" vm_name)"
if ! gcr_gitea_runner_disabled "$repo" "$runner_name" true \
|| ! gcr_vm_runner_service "$vm_id" stop; then
gcr_lock_release idle-pool
if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" \
idle-stop-failed false; then
gcr_event "vm-destroyed" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"idle-stop-failed\",\"via\":\"reconcile\"}"
else
gcr_event "vm-cleanup-pending" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"idle-stop-failed\",\"via\":\"reconcile\"}"
fi
gcr_lock_release "$key"
continue
fi
gcr_record_put "$job_id" "$attempt" "$idle_rec"
idle_expires="$(gcr_record_field "$idle_rec" idle_expires_at)"
gcr_lock_release idle-pool
gcr_lock_release "$key"
gcr_event "vm-idle" "$job_id" "{\"vm_id\":$vm_id,\"expires_at\":$idle_expires,\"via\":\"reconcile\"}"
gcr_log info --ns=sweep "job=$job_id succeeded, retaining vm=$vm_id until $idle_expires"
continue
fi
gcr_log info --ns=sweep "job=$job_id terminal ($state), destroying vm=$vm_id"
if [ -n "$vm_id" ] && [ "$vm_id" != "0" ] && [ "$vm_id" != "null" ]; then
case "$state" in
completed:success|completed:cancelled|completed:skipped) ;;
*)
ip="$(gcr_vm_public_ip "$vm_id" || true)"
gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" "$state" || true
;;
esac
if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" \
job-completed false; then
gcr_event "vm-destroyed" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"job-completed\",\"state\":\"$state\"}"
else
gcr_event "vm-cleanup-pending" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"job-completed\",\"state\":\"$state\"}"
fi
else
gcr_record_del "$job_id" "$attempt"
fi
finish_status=0
gcr_vm_finish_terminal "$job_id" "$attempt" "$rec" "$state" reconcile \
|| finish_status="$?"
gcr_lock_release "$key"
case "$finish_status" in
0|2) ;;
*) return "$finish_status" ;;
esac
;;
esac
done
+76 -2
View File
@@ -409,7 +409,11 @@ gcr_vm_public_ip() {
# The controller starts the service only after the claim record is written.
gcr_vm_runner_service() {
vm_id="$1"; action="$2"
case "$action" in start|stop) ;; *) return 1 ;; esac
case "$action" in
start|stop) service_command="systemctl $action gitea-runner.service" ;;
health) service_command="systemctl is-active --quiet gitea-runner.service" ;;
*) return 1 ;;
esac
ip="$(gcr_vm_public_ip "$vm_id")" || return 1
[ -n "$ip" ] || return 1
test -n "${GCR_SSH_PRIVKEY_FILE:-}" && test -r "$GCR_SSH_PRIVKEY_FILE" || return 1
@@ -418,7 +422,7 @@ gcr_vm_runner_service() {
printf '\n' >> "$key_tmp"
chmod 0600 "$key_tmp"
ssh_opts="-i $key_tmp -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -o BatchMode=yes"
if timeout 30 ssh $ssh_opts "root@$ip" "systemctl $action gitea-runner.service"; then
if timeout 30 ssh $ssh_opts "root@$ip" "$service_command"; then
rm -f "$key_tmp"
return 0
fi
@@ -468,6 +472,76 @@ gcr_vm_collect_diagnostics() {
return 0
}
# Finish a terminal job under its allocation lock. Healthy bootstrapped VMs
# become idle until their existing billing boundary; every unsafe transition
# uses durable cleanup_pending teardown instead.
gcr_vm_finish_terminal() {
finish_job="$1"; finish_attempt="$2"; finish_rec="$3"; finish_state="$4"
finish_via="${5:-webhook}"
finish_vm_id="$(gcr_record_field "$finish_rec" vm_id)"
if [ -n "$finish_vm_id" ] && [ "$finish_vm_id" != "null" ] \
&& [ "$finish_vm_id" != "0" ]; then
case "$finish_state" in
completed:success|completed:cancelled|completed:skipped) ;;
completed:*)
finish_ip="$(gcr_vm_public_ip "$finish_vm_id" || true)"
gcr_vm_collect_diagnostics "$finish_vm_id" "$finish_ip" \
"$finish_job" "$finish_state" || true
;;
esac
fi
if finish_idle_rec="$(gcr_record_idle_json "$finish_rec")"; then
gcr_lock_acquire idle-pool || return 2
finish_repo="$(gcr_record_field "$finish_rec" repo)"
finish_runner="$(gcr_record_field "$finish_rec" vm_name)"
if ! gcr_vm_runner_service "$finish_vm_id" health; then
gcr_lock_release idle-pool
finish_cleanup_reason=idle-health-failed
elif ! gcr_gitea_runner_disabled "$finish_repo" "$finish_runner" true \
|| ! gcr_vm_runner_service "$finish_vm_id" stop; then
gcr_lock_release idle-pool
finish_cleanup_reason=idle-stop-failed
elif ! gcr_record_put "$finish_job" "$finish_attempt" "$finish_idle_rec"; then
gcr_lock_release idle-pool
finish_cleanup_reason=idle-state-write-failed
else
finish_expires="$(gcr_record_field "$finish_idle_rec" idle_expires_at)"
gcr_lock_release idle-pool
gcr_event "vm-idle" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"expires_at\":$finish_expires,\"via\":\"$finish_via\"}"
gcr_log info --ns=sweep \
"job=$finish_job terminal ($finish_state), retaining vm=$finish_vm_id until $finish_expires"
return 0
fi
if gcr_vm_cleanup_start "$finish_job" "$finish_attempt" "$finish_rec" \
"$finish_cleanup_reason" false; then
gcr_event "vm-destroyed" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"reason\":\"$finish_cleanup_reason\",\"via\":\"$finish_via\"}"
else
gcr_event "vm-cleanup-pending" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"reason\":\"$finish_cleanup_reason\",\"via\":\"$finish_via\"}"
fi
return 0
fi
if [ -n "$finish_vm_id" ] && [ "$finish_vm_id" != "null" ] \
&& [ "$finish_vm_id" != "0" ]; then
if gcr_vm_cleanup_start "$finish_job" "$finish_attempt" "$finish_rec" \
"$finish_state" false; then
gcr_event "vm-destroyed" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"reason\":\"$finish_state\",\"via\":\"$finish_via\"}"
else
gcr_event "vm-cleanup-pending" "$finish_job" \
"{\"vm_id\":$finish_vm_id,\"reason\":\"$finish_state\",\"via\":\"$finish_via\"}"
fi
else
gcr_record_del "$finish_job" "$finish_attempt"
fi
}
# Bootstrap delivery is SSH-push from the controller. The MicroOS snapshot's
# cloud-init cannot fetch user-data (Hetzner datasource DHCP failure), so the
# controller drives provisioning over SSH using GCR_SSH_PRIVKEY_FILE, whose
+2 -2
View File
@@ -98,8 +98,8 @@ gcr_now_epoch() {
date -u '+%s'
}
# Successful VMs remain reusable until next billing-hour boundary, but never
# beyond profile hard TTL. Prints updated idle record when retention is safe.
# Healthy bootstrapped VMs remain reusable until next billing-hour boundary,
# but never beyond profile hard TTL. Prints updated idle record when safe.
gcr_record_idle_json() {
gcr_idle_rec="$1"
[ "$(gcr_record_field "$gcr_idle_rec" bootstrapped)" = "true" ] || return 1
+8 -47
View File
@@ -155,6 +155,7 @@ gcr_alloc() {
vm_id="$(gcr_record_field "$reused" vm_id)"
vm_name="$(gcr_record_field "$reused" vm_name)"
if gcr_vm_runner_service "$vm_id" start \
&& gcr_vm_runner_service "$vm_id" health \
&& gcr_gitea_runner_disabled "$repo" "$vm_name" false; then
reused="$(gcr_record_get "$job_id" "$attempt")"
reused="$(printf '%s' "$reused" | jq -c '.bootstrapped = true | del(.reused_vm)')"
@@ -247,54 +248,14 @@ gcr_deallocate() {
;;
esac
vm_id="$(gcr_record_field "$rec" vm_id)"
if [ "$new_status" = "completed:success" ] \
&& idle_rec="$(gcr_record_idle_json "$rec")"; then
if ! gcr_lock_acquire idle-pool; then
gcr_lock_release "$key"
return 0
fi
runner_name="$(gcr_record_field "$rec" vm_name)"
if ! gcr_gitea_runner_disabled "$(gcr_record_field "$rec" repo)" "$runner_name" true \
|| ! gcr_vm_runner_service "$vm_id" stop; then
gcr_lock_release idle-pool
if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" idle-stop-failed false; then
gcr_event "vm-destroyed" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"idle-stop-failed\"}"
else
gcr_event "vm-cleanup-pending" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"idle-stop-failed\"}"
fi
gcr_lock_release "$key"
return 0
fi
gcr_record_put "$job_id" "$attempt" "$idle_rec"
idle_expires="$(gcr_record_field "$idle_rec" idle_expires_at)"
gcr_lock_release idle-pool
gcr_lock_release "$key"
gcr_event "vm-idle" "$job_id" "{\"vm_id\":$vm_id,\"expires_at\":$idle_expires}"
return 0
fi
if [ -n "$vm_id" ] && [ "$vm_id" != "null" ] && [ "$vm_id" != "0" ]; then
case "$new_status" in
completed:success|completed:cancelled|completed:skipped) ;;
completed:*)
ip="$(gcr_vm_public_ip "$vm_id" || true)"
gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" "$new_status" || true
;;
esac
if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" "$new_status" false; then
gcr_event "vm-destroyed" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"$new_status\"}"
else
gcr_event "vm-cleanup-pending" "$job_id" \
"{\"vm_id\":$vm_id,\"reason\":\"$new_status\"}"
fi
else
gcr_record_del "$job_id" "$attempt"
fi
finish_status=0
gcr_vm_finish_terminal "$job_id" "$attempt" "$rec" "$new_status" webhook \
|| finish_status="$?"
gcr_lock_release "$key"
case "$finish_status" in
0|2) return 0 ;;
*) return "$finish_status" ;;
esac
}
gcr_mark_in_progress() {
+15 -12
View File
@@ -18,20 +18,26 @@
let
pname = "gitea";
version = "1.26.2";
version = "1.27.3";
src = ./source;
pnpm = pnpm_10;
pnpmPatches = [ ./pnpm-engine.patch ];
frontend = stdenv.mkDerivation {
pname = "gitea-frontend";
inherit src version;
patches = pnpmPatches;
pnpmDeps = fetchPnpmDeps {
pname = "gitea-frontend";
inherit version src;
inherit pnpm;
patches = pnpmPatches;
fetcherVersion = 3;
hash = "sha256-Qo0DLuZv+2GVLsBfCv/6CC9E/qhSE4HwV4StQL4HX4Y=";
prePnpmInstall = ''
pnpm config set engine-strict false
'';
hash = "sha256-H1sNMKRkoPlkheJFJVaof4bJ4gQHnbhosJaUqj9X8Gg=";
};
nativeBuildInputs = [
@@ -40,6 +46,10 @@ let
pnpm
];
prePnpmInstall = ''
pnpm config set engine-strict false
'';
buildPhase = ''
make frontend
'';
@@ -54,7 +64,8 @@ buildGo126Module rec {
inherit pname version src;
proxyVendor = true;
vendorHash = "sha256-7+M1n8RSgB3gZ/2na4RF9kYOf90H0bnsJZMDKpgAy64=";
deleteVendor = true;
vendorHash = "sha256-YRBMGWKIZgMxOXaXG2bIBj1XzkhSwiMyfRy+yQGw+Bo=";
outputs = [
"out"
@@ -63,17 +74,8 @@ buildGo126Module rec {
patches = [ ./static-root-path.patch ];
overrideModAttrs = _: {
postPatch = ''
substituteInPlace go.mod \
--replace-fail "go 1.26.3" "go 1.26"
'';
};
postPatch = ''
substituteInPlace modules/setting/server.go --subst-var data
substituteInPlace go.mod \
--replace-fail "go 1.26.3" "go 1.26"
'';
subPackages = [ "." ];
@@ -93,6 +95,7 @@ buildGo126Module rec {
];
postInstall = ''
mv "$out/bin/gitea.dev" "$out/bin/gitea"
mkdir $data
ln -s ${frontend}/public $data/public
cp -R ./{templates,options} $data
+13
View File
@@ -0,0 +1,13 @@
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
- "packageManager": "pnpm@11.9.0",
+ "packageManager": "pnpm@10.12.4",
"engines": {
"node": ">= 22.18.0",
- "pnpm": ">= 11.0.0"
+ "pnpm": ">= 10.0.0"
},
+1 -4
View File
@@ -37,10 +37,7 @@ groups:
name: BUGFIXES
labels:
- type/bug
-
name: API
labels:
- modifies/api
-
name: TESTING
labels:
@@ -1,6 +1,6 @@
{
"name": "Gitea DevContainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"image": "mcr.microsoft.com/devcontainers/go:1.26-trixie",
"containerEnv": {
// override "local" from packaged version
"GOTOOLCHAIN": "auto"
-8
View File
@@ -40,9 +40,7 @@ cpu.out
*.log
/gitea
/gitea-vet
/debug
/integrations.test
/bin
/dist
@@ -54,12 +52,6 @@ cpu.out
/indexers
/log
/tests/integration/gitea-integration-*
/tests/integration/indexers-*
/tests/e2e/gitea-e2e-*
/tests/e2e/indexers-*
/tests/e2e/reports
/tests/e2e/test-artifacts
/tests/e2e/test-snapshots
/tests/*.ini
/node_modules
/yarn.lock
+1 -1
View File
@@ -18,7 +18,7 @@ indent_style = tab
[templates/custom/*.tmpl]
insert_final_newline = false
[templates/swagger/v1_json.tmpl]
[templates/swagger/*_json.tmpl]
indent_style = space
insert_final_newline = false
+1
View File
@@ -4,6 +4,7 @@
/assets/*.json linguist-generated
/public/assets/img/svg/*.svg linguist-generated
/templates/swagger/v1_json.tmpl linguist-generated
/templates/swagger/v1_openapi3_json.tmpl linguist-generated
/options/fileicon/** linguist-generated
/vendor/** -text -eol linguist-vendored
/web_src/js/vendor/** -text -eol linguist-vendored
@@ -1,42 +0,0 @@
<!-- NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue -->
<!--
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Please take a moment to check that your issue doesn't already exist.
4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq)
5. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report.
-->
- Gitea version (or commit ref):
- Git version:
- Operating system:
<!-- Please include information on whether you built gitea yourself, used one of our downloads or are using some other package -->
<!-- Please also tell us how you are running gitea, e.g. if it is being run from docker, a command-line, systemd etc. --->
<!-- If you are using a package or systemd tell us what distribution you are using -->
- Database (use `[x]`):
- [ ] PostgreSQL
- [ ] MySQL
- [ ] MSSQL
- [ ] SQLite
- Can you reproduce the bug at https://demo.gitea.com:
- [ ] Yes (provide example URL)
- [ ] No
- Log gist:
<!-- It really is important to provide pertinent logs -->
<!-- Please read https://docs.gitea.com/administration/logging-config#collecting-logs-for-help -->
<!-- In addition, if your problem relates to git commands set `RUN_MODE=dev` at the top of app.ini -->
## Description
<!-- If using a proxy or a CDN (e.g. CloudFlare) in front of gitea, please
disable the proxy/CDN fully and connect to gitea directly to confirm
the issue still persists without those services. -->
...
## Screenshots
<!-- **If this issue involves the Web Interface, please include a screenshot** -->
+10 -73
View File
@@ -1,91 +1,28 @@
name: Bug Report
description: Found something you weren't expecting? Report it here!
description: Something isn't working as expected.
labels: ["type/bug"]
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue.
- type: markdown
attributes:
value: |
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Make sure you are using the latest release and
take a moment to check that your issue hasn't been reported before.
4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq)
5. It's really important to provide pertinent details and logs (https://docs.gitea.com/help/support),
incomplete details will be handled as an invalid report.
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below)
If you are using a proxy or a CDN (e.g. Cloudflare) in front of Gitea, please disable the proxy/CDN fully and access Gitea directly to confirm the issue still persists without those services.
- **Security issue?** Email security@gitea.io instead of opening a public issue.
- **Need help** with setup or configuration? Ask on [Discord](https://discord.gg/Gitea) or the [forum](https://forum.gitea.com).
- Search [existing issues](https://github.com/go-gitea/gitea/issues?q=is%3Aissue) first.
- type: input
id: gitea-ver
attributes:
label: Gitea Version
description: Gitea version (or commit reference) of your instance
validations:
required: true
- type: dropdown
id: can-reproduce
- type: textarea
id: description
attributes:
label: Can you reproduce the bug on the Gitea demo site?
description: |
If so, please provide a URL in the Description field
URL of Gitea demo: https://demo.gitea.com
options:
- "Yes"
- "No"
label: What happened?
description: What you did, what you expected to happen, and what happened instead. Include logs if relevant.
validations:
required: true
- type: markdown
attributes:
value: |
It's really important to provide pertinent logs
Please read https://docs.gitea.com/administration/logging-config#collecting-logs-for-help
In addition, if your problem relates to git commands set `RUN_MODE=dev` at the top of app.ini
- type: input
id: logs
attributes:
label: Log Gist
description: Please provide a gist URL of your logs, with any sensitive information (e.g. API keys) removed/hidden
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If this issue involves the Web Interface, please provide one or more screenshots
- type: input
id: git-ver
attributes:
label: Git Version
description: The version of git running on the server
- type: input
id: os-ver
attributes:
label: Operating System
description: The operating system you are using to run Gitea
- type: textarea
id: run-info
id: environment
attributes:
label: How are you running Gitea?
description: |
Please include information on whether you built Gitea yourself, used one of our downloads, are using https://demo.gitea.com or are using some other package
Please also tell us how you are running Gitea, e.g. if it is being run from docker, a command-line, systemd etc.
If you are using a package or systemd tell us what distribution you are using
validations:
required: true
- type: dropdown
id: database
attributes:
label: Database
description: What database system are you running?
options:
- PostgreSQL
- MySQL/MariaDB
- MSSQL
- SQLite
description: Install method (binary, Docker, package), operating system, and database.
@@ -1,24 +1,20 @@
name: Feature Request
description: Got an idea for a feature that Gitea doesn't have currently? Submit your idea here!
description: Suggest an idea for Gitea.
labels: ["type/proposal"]
body:
- type: markdown
attributes:
value: |
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Please take a moment to check that your feature hasn't already been suggested.
Search [existing issues](https://github.com/go-gitea/gitea/issues?q=is%3Aissue) first.
- type: textarea
id: description
id: problem
attributes:
label: Feature Description
placeholder: |
I think it would be great if Gitea had...
label: What problem would this solve?
validations:
required: true
- type: textarea
id: screenshots
id: proposal
attributes:
label: Screenshots
description: If you can, provide screenshots of an implementation on another site e.g. GitHub
label: What do you propose?
validations:
required: true
@@ -1,66 +0,0 @@
name: Web Interface Bug Report
description: Something doesn't look quite as it should? Report it here!
labels: ["type/bug", "topic/ui"]
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue.
- type: markdown
attributes:
value: |
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Please take a moment to check that your issue doesn't already exist.
4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq)
5. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report.
6. In particular it's really important to provide pertinent logs. If you are certain that this is a javascript
error, show us the javascript console. If the error appears to relate to Gitea the server you must also give us
DEBUG level logs. (See https://docs.gitea.com/administration/logging-config#collecting-logs-for-help)
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below)
If using a proxy or a CDN (e.g. CloudFlare) in front of gitea, please disable the proxy/CDN fully and connect to gitea directly to confirm the issue still persists without those services.
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Please provide at least 1 screenshot showing the issue.
validations:
required: true
- type: input
id: gitea-ver
attributes:
label: Gitea Version
description: Gitea version (or commit reference) your instance is running
validations:
required: true
- type: dropdown
id: can-reproduce
attributes:
label: Can you reproduce the bug on the Gitea demo site?
description: |
If so, please provide a URL in the Description field
URL of Gitea demo: https://demo.gitea.com
options:
- "Yes"
- "No"
validations:
required: true
- type: input
id: os-ver
attributes:
label: Operating System
description: The operating system you are using to access Gitea
- type: input
id: browser-ver
attributes:
label: Browser Version
description: The browser and version that you are using to access Gitea
validations:
required: true
@@ -0,0 +1,29 @@
name: docker-dryrun
description: Composite action that performs the container build steps for a single platform.
inputs:
platform:
description: "The target platform: linux/amd64, linux/arm64, linux/riscv64."
required: true
runs:
using: composite
steps:
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build regular image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: ${{ inputs.platform }}
push: false
file: Dockerfile
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
- name: Build rootless image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: ${{ inputs.platform }}
push: false
file: Dockerfile.rootless
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless
@@ -0,0 +1,17 @@
name: free-disk-space
description: Free space on / before large cache restores
# Delete preinstalled toolchains which gitea doesn't use and show disk space usage
runs:
using: composite
steps:
- shell: bash
run: |
echo "free space before cleanup:"
df -h /
for dir in /usr/local/lib/android /usr/local/.ghcup /opt/ghc /usr/share/dotnet; do
sudo rm -rf "$dir" &
done
wait
echo "free space after cleanup:"
df -h /
@@ -0,0 +1,50 @@
name: go-caches
description: Restore the go module, build, and golangci-lint caches. Save only on the cache-seeder workflow.
# Only the cache-seeder workflow saves; rename requires updating cache-seeder.yml.
# The lint job restores but does not save the gobuild cache, so only one writer
# (the gobuild job) populates it and there is no contention on the cache key.
# Seeder restores by exact key only (no restore-keys) so each go.sum seeds a clean
# cache and size stays bounded; do not add restore-keys here. PR runs keep them.
inputs:
lint-cache:
description: Restore (and save in cache-seeder) ~/.cache/golangci-lint
default: "false"
runs:
using: composite
steps:
- if: ${{ github.workflow == 'cache-seeder' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
- if: ${{ github.workflow != 'cache-seeder' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
restore-keys: gomod-${{ runner.os }}-${{ runner.arch }}
- if: ${{ github.workflow == 'cache-seeder' && inputs.lint-cache != 'true' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/go-build
key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
- if: ${{ github.workflow != 'cache-seeder' || inputs.lint-cache == 'true' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/go-build
key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
restore-keys: gobuild-${{ runner.os }}-${{ runner.arch }}
- if: ${{ inputs.lint-cache == 'true' && github.workflow == 'cache-seeder' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/golangci-lint
key: golint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum', '.golangci.yml') }}
- if: ${{ inputs.lint-cache == 'true' && github.workflow != 'cache-seeder' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/golangci-lint
key: golint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum', '.golangci.yml') }}
restore-keys: golint-${{ runner.os }}-${{ runner.arch }}
@@ -0,0 +1,24 @@
name: go-setup
description: Set up go and restore caches
inputs:
cache:
description: Restore go caches
default: "true"
lint-cache:
description: Also restore the golangci-lint cache
default: "false"
runs:
using: composite
steps:
- uses: ./.github/actions/free-disk-space
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
check-latest: true
cache: false
- if: ${{ inputs.cache == 'true' }}
uses: ./.github/actions/go-cache
with:
lint-cache: ${{ inputs.lint-cache }}
@@ -0,0 +1,22 @@
name: node-setup
description: Set up pnpm and node and restore caches
inputs:
cache:
description: Cache pnpm downloads
default: "true"
runs:
using: composite
steps:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- if: ${{ inputs.cache == 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- if: ${{ inputs.cache != 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
@@ -0,0 +1,40 @@
name: pgsql-shard
description: Run one pgsql integration test shard
inputs:
shard:
description: Shard index
required: true
total-shards:
description: Total shard count
required: true
run-migration:
description: Also run migration tests
default: "false"
runs:
using: composite
steps:
- name: Add hosts to /etc/hosts
shell: bash
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts'
- shell: bash
run: make deps-backend
- shell: bash
run: make backend
env:
TAGS: bindata
- name: run migration tests
if: ${{ inputs.run-migration == 'true' }}
shell: bash
run: GITEA_TEST_DATABASE=pgsql make test-migration
- name: run tests
shell: bash
run: GITEA_TEST_DATABASE=pgsql make test-integration
env:
# pgsql is chosen to be the unlucky one to run with the slow "race detector", it is about 60% slower.
GOTEST_FLAGS: -race -timeout=40m
TAGS: bindata gogit
TEST_LDAP: 1
TEST_SHARD: ${{ inputs.shard }}
TEST_TOTAL_SHARDS: ${{ inputs.total-shards }}
-10
View File
@@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
labels: [modifies/dependencies]
directory: /
schedule:
interval: daily
cooldown:
default-days: 5
-77
View File
@@ -1,80 +1,3 @@
modifies/docs:
- changed-files:
- any-glob-to-any-file:
- "**/*.md"
- "docs/**"
modifies/templates:
- changed-files:
- all-globs-to-any-file:
- "templates/**"
- "!templates/swagger/v1_json.tmpl"
modifies/api:
- changed-files:
- any-glob-to-any-file:
- "routers/api/**"
- "templates/swagger/v1_json.tmpl"
modifies/cli:
- changed-files:
- any-glob-to-any-file:
- "cmd/**"
modifies/translation:
- changed-files:
- any-glob-to-any-file:
- "options/locale/*.ini"
modifies/migrations:
- changed-files:
- any-glob-to-any-file:
- "models/migrations/**"
modifies/internal:
- changed-files:
- any-glob-to-any-file:
- ".air.toml"
- "Makefile"
- "Dockerfile"
- "Dockerfile.rootless"
- ".dockerignore"
- "docker/**"
- ".editorconfig"
- ".eslintrc.cjs"
- ".golangci.yml"
- ".markdownlint.yaml"
- ".spectral.yaml"
- "stylelint.config.*"
- ".yamllint.yaml"
- ".github/**"
- ".gitea/**"
- ".devcontainer/**"
- "build/**"
- "contrib/**"
modifies/dependencies:
- changed-files:
- any-glob-to-any-file:
- "package.json"
- "pnpm-lock.yaml"
- "pyproject.toml"
- "uv.lock"
- "go.mod"
- "go.sum"
modifies/go:
- changed-files:
- any-glob-to-any-file:
- "**/*.go"
modifies/frontend:
- changed-files:
- any-glob-to-any-file:
- "*.js"
- "*.ts"
- "web_src/**"
docs-update-needed:
- changed-files:
- any-glob-to-any-file:
+9 -10
View File
@@ -1,10 +1,9 @@
<!-- start tips -->
Please check the following:
1. Make sure you are targeting the `main` branch, pull requests on release branches are only allowed for backports.
2. Make sure you have read contributing guidelines: https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md .
3. For documentations contribution, please go to https://gitea.com/gitea/docs
4. Describe what your pull request does and which issue you're targeting (if any).
5. It is recommended to enable "Allow edits by maintainers", so maintainers can help more easily.
6. Your input here will be included in the commit message when this PR has been merged. If you don't want some content to be included, please separate them with a line like `---`.
7. Delete all these tips before posting.
<!-- end tips -->
<!--
Before submitting:
- Target the `main` branch; release branches are for backports only.
- Use a Conventional Commits title, e.g. `fix(repo): handle empty branch names`.
- Read the contributing guidelines: https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md
- Documentation changes go to https://gitea.com/gitea/docs
Describe your change below and link any issue it fixes.
-->
+72
View File
@@ -0,0 +1,72 @@
# Populates main's cache scope so PR runs warm-start from it. Saves the go
# module, go build (incl. test compile), and golangci-lint caches.
#
# Caches are ref-scoped: PR runs read their own scope then fall back to the
# base branch. Per .github/actions/go-cache/action.yml, PRs are restore-only,
# so push-to-main is the only opportunity to populate the fallback scope.
name: cache-seeder
on:
push:
branches:
- main
paths:
- "go.sum"
- ".golangci.yml"
- ".github/actions/go-cache/action.yml"
- ".github/actions/go-setup/action.yml"
- ".github/workflows/cache-seeder.yml"
concurrency:
group: cache-seeder
cancel-in-progress: true
permissions:
contents: read
jobs:
gobuild:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- run: make deps-backend deps-tools
- run: TAGS="bindata" make backend
- run: TAGS="bindata gogit" GOEXPERIMENT="" make backend
- name: warm test compile cache (bindata)
env:
TAGS: bindata
GOTEST_FLAGS: -race -list=^$$ -count=1
run: make test-backend
- name: warm test compile cache (bindata gogit)
env:
TAGS: bindata gogit
GOEXPERIMENT:
GOTEST_FLAGS: -race -list=^$$ -count=1
run: make test-backend
- name: warm integration compile cache
run: |
TAGS="bindata" make test-integration-compile
TAGS="bindata gogit" GOEXPERIMENT="" make test-integration-compile
TAGS="bindata gogit" GOTEST_FLAGS="-race" make test-integration-compile
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { tags: "bindata", target: "lint-backend" }
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
with:
lint-cache: "true"
- run: make deps-backend deps-tools
- run: make generate-go
env:
TAGS: ${{ matrix.tags }}
- run: make ${{ matrix.target }}
env:
TAGS: ${{ matrix.tags }}
@@ -1,22 +0,0 @@
name: cron-flake-updater
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0' # runs weekly on Sunday at 00:00
jobs:
nix-flake-update:
permissions:
contents: write
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@v3
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "Update Nix flake"
pr-labels: |
dependencies
+3 -3
View File
@@ -12,15 +12,15 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
check-latest: true
- run: make generate-gitignore
timeout-minutes: 40
- name: push translations to repo
uses: appleboy/git-push-action@v1.2.0
uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0
with:
author_email: "teabot@gitea.io"
author_name: GiteaBot
@@ -0,0 +1,32 @@
name: cron-renovate
on:
schedule:
- cron: "23 * * * *" # hourly at :23
workflow_dispatch:
concurrency:
group: cron-renovate
env:
RENOVATE_VERSION: 43.141.5 # renovate: datasource=docker depName=ghcr.io/renovatebot/renovate
permissions:
contents: read
jobs:
cron-renovate:
runs-on: ubuntu-latest
if: github.repository == 'go-gitea/gitea' # prevent running on forks
timeout-minutes: 30
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: renovatebot/github-action@6d859fc95779be83a0335ca704879b47e5d79641 # v46.1.16
with:
renovate-version: ${{ env.RENOVATE_VERSION }}
configurationFile: renovate.json5
token: ${{ secrets.RENOVATE_TOKEN }}
env:
RENOVATE_BINARY_SOURCE: install # auto-install go/node toolchains needed by post-upgrade tasks.
RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^make (tidy|svg)$"]'
RENOVATE_REPOSITORIES: '["go-gitea/gitea"]'
@@ -12,8 +12,8 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: crowdin/github-action@v2
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_sources: true
upload_translations: false
@@ -29,7 +29,7 @@ jobs:
- name: update locales
run: ./build/update-locales.sh
- name: push translations to repo
uses: appleboy/git-push-action@v1.2.0
uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0
with:
author_email: "teabot@gitea.io"
author_name: GiteaBot
+43 -10
View File
@@ -15,19 +15,26 @@ on:
value: ${{ jobs.detect.outputs.templates }}
docker:
value: ${{ jobs.detect.outputs.docker }}
dockerfile:
value: ${{ jobs.detect.outputs.dockerfile }}
swagger:
value: ${{ jobs.detect.outputs.swagger }}
yaml:
value: ${{ jobs.detect.outputs.yaml }}
json:
value: ${{ jobs.detect.outputs.json }}
e2e:
value: ${{ jobs.detect.outputs.e2e }}
shell:
value: ${{ jobs.detect.outputs.shell }}
permissions:
contents: read
jobs:
detect:
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
outputs:
backend: ${{ steps.changes.outputs.backend }}
frontend: ${{ steps.changes.outputs.frontend }}
@@ -35,12 +42,15 @@ jobs:
actions: ${{ steps.changes.outputs.actions }}
templates: ${{ steps.changes.outputs.templates }}
docker: ${{ steps.changes.outputs.docker }}
dockerfile: ${{ steps.changes.outputs.dockerfile }}
swagger: ${{ steps.changes.outputs.swagger }}
yaml: ${{ steps.changes.outputs.yaml }}
json: ${{ steps.changes.outputs.json }}
e2e: ${{ steps.changes.outputs.e2e }}
shell: ${{ steps.changes.outputs.shell }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
filters: |
@@ -54,49 +64,61 @@ jobs:
- ".golangci.yml"
- ".editorconfig"
- "options/locale/locale_en-US.json"
- "models/fixtures/**"
- "tests/*.ini.tmpl"
- "tests/gitea-repositories-meta/**"
- "tests/testdata/**"
- "tools/test-integration.sh"
frontend:
- "*.js"
- "*.ts"
- "web_src/**"
- "tools/*.js"
- "tools/*.ts"
- "tools/generate-svg.ts"
- "tools/generate-svg-vscode-extensions.json"
- "tsconfig.json"
- "assets/emoji.json"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "Makefile"
- ".eslintrc.cjs"
- ".npmrc"
docs:
- "**/*.md"
- ".markdownlint.yaml"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
actions:
- ".github/workflows/*"
- ".github/actions/**"
- "Makefile"
templates:
- "tools/lint-templates-*.js"
- "tools/lint-templates-*.ts"
- "templates/**/*.tmpl"
- "pyproject.toml"
- "uv.lock"
docker:
- ".github/workflows/pull-docker-dryrun.yml"
- ".github/actions/docker-dryrun/**"
- "Dockerfile"
- "Dockerfile.rootless"
- "docker/**"
- "Makefile"
dockerfile:
- "Dockerfile"
- "Dockerfile.rootless"
swagger:
- "templates/swagger/v1_json.tmpl"
- "templates/swagger/v1_input.json"
- "Makefile"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".spectral.yaml"
yaml:
@@ -107,3 +129,14 @@ jobs:
json:
- "**/*.json"
- "**/*.json5"
- "eslint.json.config.ts"
e2e:
- "tests/e2e/**"
- "tools/test-e2e.sh"
- "playwright.config.ts"
shell:
- "**/*.sh"
- ".shellcheckrc"
@@ -0,0 +1,26 @@
name: giteabot backport
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
giteabot:
if: github.repository == 'go-gitea/gitea'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: go-gitea/giteabot@912675d47455ac93be82d8bda4667a02b20a6fe4 # v1.0.4
with:
github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea
checks: backport
+64
View File
@@ -0,0 +1,64 @@
name: giteabot
on:
# When main advances, rerun merge queue maintenance so the oldest
# reviewed/wait-merge PR can be updated against the new base promptly.
push:
branches:
- main
# pull_request_target gives this workflow access to GITEABOT_TOKEN on PRs from
# forks, which the bot needs to write labels, statuses and comments. Safe here
# because the job only runs a pinned action and never checks out PR HEAD.
# These PR lifecycle events drive label maintenance, queue maintenance, and
# explicit bot actions triggered by relevant label changes.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types:
- opened
- synchronize
- labeled
- unlabeled
- closed
- review_requested
- review_request_removed
# Review events keep review-derived state such as lgtm labels and status checks
# in sync after approvals, edits, or dismissals.
pull_request_review:
types:
- submitted
- edited
- dismissed
# Periodic maintenance is still useful as a backstop for queue cleanup and
# other housekeeping, even though main pushes now trigger it promptly.
schedule:
- cron: "15 3 * * *"
# Allow maintainers to rerun selected checks manually when debugging bot
# behavior without waiting for another repository event.
workflow_dispatch:
inputs:
checks:
description: Comma-separated list of non-backport checks to run
required: false
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
permissions:
contents: read
issues: write
pull-requests: write
statuses: write
concurrency:
group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }}
cancel-in-progress: false
jobs:
giteabot:
if: github.repository == 'go-gitea/gitea'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# pull_request_review runs without repository secrets on fork PRs, so fall
# back to the workflow token for the non-backport checks handled here.
- uses: go-gitea/giteabot@912675d47455ac93be82d8bda4667a02b20a6fe4 # v1.0.4
with:
github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
+37 -174
View File
@@ -7,156 +7,63 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
permissions:
contents: read
lint-backend:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
lint-cache: "true"
- run: make deps-backend deps-tools
- run: TAGS="bindata" make generate-go # lint-go also lints with "bindata" tags which requires "_bindata.go"
- run: make lint-backend
env:
TAGS: bindata sqlite sqlite_unlock_notify
lint-templates:
if: needs.files-changed.outputs.templates == 'true'
lint-on-demand:
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.0.0
- run: uv python install 3.14
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: make deps-py
- run: make deps-frontend
- run: make lint-templates
lint-yaml:
if: needs.files-changed.outputs.yaml == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v8.0.0
- run: uv python install 3.14
- run: make deps-py
- run: make lint-yaml
lint-json:
if: needs.files-changed.outputs.json == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v5
cache: "false"
- uses: ./.github/actions/node-setup
with:
node-version: 24
- run: make deps-frontend
- run: make lint-json
cache: "false"
lint-swagger:
if: needs.files-changed.outputs.swagger == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: make deps-frontend
- run: make lint-swagger
lint-spell:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.templates == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- run: make lint-spell
lint-go-windows:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true' || needs.files-changed.outputs.actions == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
go-version-file: go.mod
check-latest: true
- run: make deps-backend deps-tools
- run: make lint-go-windows lint-go-gitea-vet
env:
TAGS: bindata sqlite sqlite_unlock_notify
GOOS: windows
GOARCH: amd64
python-version: 3.14
- if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true'
run: make deps-py lint-templates lint-yaml
lint-go-gogit:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- run: make deps-backend deps-tools
- run: make lint-go
env:
TAGS: bindata gogit sqlite sqlite_unlock_notify
- if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.swagger == 'true' || needs.files-changed.outputs.json == 'true'
run: make deps-frontend lint-md lint-swagger lint-json
- if: needs.files-changed.outputs.actions == 'true'
run: make lint-actions
- if: needs.files-changed.outputs.shell == 'true'
run: make lint-shell
checks-backend:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- run: make deps-backend deps-tools
- run: make --always-make checks-backend # ensure the "go-licenses" make target runs
@@ -164,16 +71,9 @@ jobs:
if: needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/node-setup
- run: make deps-frontend
- run: make lint-frontend
- run: make checks-frontend
@@ -184,20 +84,14 @@ jobs:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
# no frontend build here as backend should be able to build
# even without any frontend files
- run: make deps-backend
- run: go build -o gitea_no_gcc # test if build succeeds without the sqlite tag
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- run: make deps-backend generate-go
# no frontend build here as backend should be able to build, even without any frontend files
# CGO is not used when cross-compile, so these steps also test if the code is compatible with CGO disabled
- name: build-backend-arm64
run: make backend # test cross compile
run: go build -o gitea_linux_arm64
env:
GOOS: linux
GOARCH: arm64
@@ -209,38 +103,7 @@ jobs:
GOARCH: amd64
TAGS: bindata gogit
- name: build-backend-386
run: go build -o gitea_linux_386 # test if compatible with 32 bit
run: go build -o gitea_linux_386
env:
GOOS: linux
GOARCH: 386
docs:
if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: make deps-frontend
- run: make lint-md
actions:
if: needs.files-changed.outputs.actions == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- run: make lint-actions
+99 -87
View File
@@ -7,18 +7,18 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
permissions:
contents: read
test-pgsql:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
test-pgsql-shard-1:
if: needs.files-changed.outputs.backend == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 50
services:
pgsql:
image: postgres:14
@@ -28,79 +28,100 @@ jobs:
ports:
- "5432:5432"
ldap:
image: gitea/test-openldap:latest
image: gitea/test-openldap:latest@sha256:4ac633b01d684e6b2a458cc0c8530c92f9b3702f6e040ce5f365607df34fbda0
ports:
- "389:389"
- "636:636"
minio:
# as github actions doesn't support "entrypoint", we need to use a non-official image
# that has a custom entrypoint set to "minio server /data"
image: bitnamilegacy/minio:2023.8.31
image: bitnamilegacy/minio:2025.7.23
env:
MINIO_ROOT_USER: 123456
MINIO_ROOT_PASSWORD: 12345678
ports:
- "9000:9000"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- uses: ./.github/actions/pgsql-shard
with:
go-version-file: go.mod
check-latest: true
- name: Add hosts to /etc/hosts
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts'
- run: make deps-backend
- run: make backend
shard: 1
total-shards: 2
run-migration: "true"
test-pgsql-shard-2:
if: needs.files-changed.outputs.backend == 'true'
needs: files-changed
runs-on: ubuntu-latest
timeout-minutes: 50
services:
pgsql:
image: postgres:14
env:
TAGS: bindata
- name: run migration tests
run: make test-pgsql-migration
- name: run tests
run: make test-pgsql
timeout-minutes: 50
POSTGRES_DB: test
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
ldap:
image: gitea/test-openldap:latest@sha256:4ac633b01d684e6b2a458cc0c8530c92f9b3702f6e040ce5f365607df34fbda0
ports:
- "389:389"
- "636:636"
minio:
# as github actions doesn't support "entrypoint", we need to use a non-official image
# that has a custom entrypoint set to "minio server /data"
image: bitnamilegacy/minio:2025.7.23
env:
TAGS: bindata gogit
RACE_ENABLED: true
TEST_TAGS: gogit
TEST_LDAP: 1
MINIO_ROOT_USER: 123456
MINIO_ROOT_PASSWORD: 12345678
ports:
- "9000:9000"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- uses: ./.github/actions/pgsql-shard
with:
shard: 2
total-shards: 2
test-sqlite:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- run: make deps-backend
- run: GOEXPERIMENT='' make backend
- run: make backend
env:
TAGS: bindata gogit sqlite sqlite_unlock_notify
- name: run migration tests
run: make test-sqlite-migration
TAGS: bindata gogit
GOEXPERIMENT:
- run: GITEA_TEST_DATABASE=sqlite make test-migration
env:
TAGS: bindata gogit
- name: run tests
run: GOEXPERIMENT='' make test-sqlite
run: GITEA_TEST_DATABASE=sqlite make test-integration
timeout-minutes: 50
env:
TAGS: bindata gogit sqlite sqlite_unlock_notify
RACE_ENABLED: true
TEST_TAGS: gogit sqlite sqlite_unlock_notify
# sqlite driver can contain large amount of Golang code, so don't use race detector for it, otherwise, extremely slow
GOTEST_FLAGS: -timeout=40m
TAGS: bindata gogit
GOEXPERIMENT:
test-unit:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
if: needs.files-changed.outputs.backend == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
services:
elasticsearch:
image: elasticsearch:7.5.0
image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15
env:
discovery.type: single-node
xpack.security.enabled: false
xpack.ml.enabled: false
ingest.geoip.downloader.enabled: false
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
ports:
- "9200:9200"
meilisearch:
@@ -110,7 +131,7 @@ jobs:
ports:
- "7700:7700"
redis:
image: redis
image: redis:latest@sha256:c904002d182255b6db3cbe3a1e8ce6c187d15390c39500b59fc07181aabff7bf
options: >- # wait until redis has started
--health-cmd "redis-cli ping"
--health-interval 5s
@@ -119,51 +140,49 @@ jobs:
ports:
- 6379:6379
minio:
image: bitnamilegacy/minio:2021.3.17
image: bitnamilegacy/minio:2025.7.23
env:
MINIO_ACCESS_KEY: 123456
MINIO_SECRET_KEY: 12345678
MINIO_ROOT_USER: 123456
MINIO_ROOT_PASSWORD: 12345678
ports:
- "9000:9000"
devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583
image: mcr.microsoft.com/azure-storage/azurite:latest
image: mcr.microsoft.com/azure-storage/azurite:latest@sha256:dae2a5f96553962901304b94e72ef87e299d0825e4b679673bcc527a25076fe4
ports:
- 10000:10000
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- name: Add hosts to /etc/hosts
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 minio devstoreaccount1.azurite.local mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts'
- run: make deps-backend
- run: make backend
- run: make generate-go
env:
TAGS: bindata
- name: unit-tests
run: make unit-test-coverage test-check
run: make test-backend
env:
GOTEST_FLAGS: -race -timeout=20m
TAGS: bindata
RACE_ENABLED: true
GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }}
- name: unit-tests-gogit
run: GOEXPERIMENT='' make unit-test-coverage test-check
run: make test-backend
env:
GOTEST_FLAGS: -race -timeout=20m
TAGS: bindata gogit
RACE_ENABLED: true
GOEXPERIMENT:
GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }}
GITEA_TEST_CI_SKIP_EXTERNAL: true
- run: make test-check
test-mysql:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
if: needs.files-changed.outputs.backend == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
services:
mysql:
# the bitnami mysql image has more options than the official one, it's easier to customize
image: bitnamilegacy/mysql:8.0
image: bitnamilegacy/mysql:8.4
env:
ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: testgitea
@@ -172,46 +191,42 @@ jobs:
options: >-
--mount type=tmpfs,destination=/bitnami/mysql/data
elasticsearch:
image: elasticsearch:7.5.0
image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15
env:
discovery.type: single-node
xpack.security.enabled: false
xpack.ml.enabled: false
ingest.geoip.downloader.enabled: false
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
ports:
- "9200:9200"
smtpimap:
image: tabascoterrier/docker-imap-devel:latest
image: tabascoterrier/docker-imap-devel:latest@sha256:3fb7cf50b47693e7b80f6f74abea2def4d7386016931d61359864de8a0aba551
ports:
- "25:25"
- "143:143"
- "587:587"
- "993:993"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- name: Add hosts to /etc/hosts
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts'
- run: make deps-backend
- run: make backend
env:
TAGS: bindata
- name: run migration tests
run: make test-mysql-migration
- run: GITEA_TEST_DATABASE=mysql make test-migration
- name: run tests
# run: make integration-test-coverage (at the moment, no coverage is really handled)
run: make test-mysql
run: GITEA_TEST_DATABASE=mysql make test-integration
env:
TAGS: bindata
RACE_ENABLED: true
TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200"
test-mssql:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
if: needs.files-changed.outputs.backend == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
services:
mssql:
image: mcr.microsoft.com/mssql/server:2019-latest
@@ -222,24 +237,21 @@ jobs:
ports:
- "1433:1433"
devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583
image: mcr.microsoft.com/azure-storage/azurite:latest
image: mcr.microsoft.com/azure-storage/azurite:latest@sha256:dae2a5f96553962901304b94e72ef87e299d0825e4b679673bcc527a25076fe4
ports:
- 10000:10000
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- name: Add hosts to /etc/hosts
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql devstoreaccount1.azurite.local" | sudo tee -a /etc/hosts'
- run: make deps-backend
- run: make backend
env:
TAGS: bindata
- run: make test-mssql-migration
- run: GITEA_TEST_DATABASE=mssql make test-migration
- name: run tests
run: make test-mssql
run: GITEA_TEST_DATABASE=mssql make test-integration
timeout-minutes: 50
env:
TAGS: bindata
+29 -22
View File
@@ -7,34 +7,41 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
permissions:
contents: read
container:
# QEMU-based build is slow (40-50 minutes), so run arm64 and riscv64 when dockerfile changes.
# Run amd64 when any docker-related files change, which is fast (4 minutes).
container-amd64:
if: needs.files-changed.outputs.docker == 'true'
needs: files-changed
needs: [files-changed]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- name: Build regular container image
uses: docker/build-push-action@v7
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/docker-dryrun
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
push: false
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
- name: Build rootless container image
uses: docker/build-push-action@v7
platform: linux/amd64
container-arm64:
if: needs.files-changed.outputs.dockerfile == 'true'
needs: [files-changed]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/docker-dryrun
with:
context: .
push: false
platforms: linux/amd64,linux/arm64,linux/riscv64
file: Dockerfile.rootless
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless
platform: linux/arm64
container-riscv64:
if: needs.files-changed.outputs.dockerfile == 'true'
needs: [files-changed]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/docker-dryrun
with:
platform: linux/riscv64
+11 -17
View File
@@ -7,37 +7,31 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
permissions:
contents: read
test-e2e:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true'
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.e2e == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
check-latest: true
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- uses: ./.github/actions/node-setup
- run: make deps-frontend
- run: make frontend
- run: make deps-backend
- run: make gitea-e2e
- run: make backend
env:
TAGS: bindata
- run: make playwright
- run: make test-e2e
timeout-minutes: 10
env:
TAGS: bindata
FORCE_COLOR: 1
GITEA_TEST_E2E_DEBUG: 1
+30 -3
View File
@@ -1,8 +1,10 @@
name: labeler
on:
pull_request_target:
types: [opened, synchronize, reopened]
# pull_request_target is required to label PRs from forks; jobs only use pinned
# actions or base-branch checkout, never PR-head code.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, synchronize, reopened, edited, ready_for_review]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -15,6 +17,31 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/labeler@v6
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with:
sync-labels: true
pr-title:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
# Base-branch checkout only: pull_request_target runs with elevated token; never run PR-head code here.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
# Labels are only synced after the title lints, so an invalid title never reaches the label diff.
- run: node ./tools/ci-tools.ts lint-pr-title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
- run: node ./tools/ci-tools.ts set-pr-labels
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ github.token }}
@@ -0,0 +1,45 @@
name: release-nightly-snapcraft
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-publish:
runs-on: ubuntu-latest
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install snapcraft
run: sudo snap install snapcraft --classic
- name: Authenticate snapcraft
shell: bash
run: snapcraft login --with <(printf '%s' "$SNAPCRAFT_STORE_CREDENTIALS")
- name: Remote build
run: |
snapcraft remote-build \
--launchpad-accept-public-upload \
--build-for=amd64,arm64,armhf
- name: List built snaps
run: find . -maxdepth 1 -type f -name '*.snap' -print
- name: Upload and release snapcraft nightly build
run: |
set -euo pipefail
for snap in ./*.snap; do
echo "Uploading $snap to edge"
snapcraft upload --release="latest/edge" "$snap"
done
+39 -27
View File
@@ -13,17 +13,18 @@ jobs:
runs-on: namespace-profile-gitea-release-binary
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: actions/setup-go@v6
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
check-latest: true
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
@@ -32,34 +33,43 @@ jobs:
# xgo build
- run: make release
env:
TAGS: bindata sqlite sqlite_unlock_notify
TAGS: bindata
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: import gpg key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: sign binaries
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
run: |
for f in dist/release/*; do
echo '${{ secrets.GPGSIGN_PASSPHRASE }}' | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u ${{ steps.import_gpg.outputs.fingerprint }} --output "$f.asc" "$f"
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
done
# clean branch name to get the folder name in S3
# clean branch name to get the folder name in the object storage
- name: Get cleaned branch name
id: clean_name
env:
REF: ${{ github.ref }}
run: |
REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//')
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//')
echo "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
- name: configure aws
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ secrets.AWS_REGION }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: upload binaries to s3
- name: upload binaries to cloudflare r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
nightly-container:
runs-on: namespace-profile-gitea-release-docker
@@ -67,18 +77,20 @@ jobs:
contents: read
packages: write # to publish to ghcr.io
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Get cleaned branch name
id: clean_name
env:
REF: ${{ github.ref }}
run: |
REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//')
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//')
echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
- uses: docker/metadata-action@v6
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta
with:
images: |-
@@ -88,7 +100,7 @@ jobs:
type=raw,value=${{ steps.clean_name.outputs.branch }}
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- uses: docker/metadata-action@v6
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta_rootless
with:
images: |-
@@ -102,18 +114,18 @@ jobs:
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR using PAT
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build regular docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
@@ -123,7 +135,7 @@ jobs:
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
cache-to: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful,mode=max
- name: build rootless docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
+40 -29
View File
@@ -14,17 +14,18 @@ jobs:
runs-on: namespace-profile-gitea-release-binary
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: actions/setup-go@v6
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
check-latest: true
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
@@ -33,43 +34,53 @@ jobs:
# xgo build
- run: make release
env:
TAGS: bindata sqlite sqlite_unlock_notify
TAGS: bindata
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: import gpg key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: sign binaries
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
run: |
for f in dist/release/*; do
echo '${{ secrets.GPGSIGN_PASSPHRASE }}' | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u ${{ steps.import_gpg.outputs.fingerprint }} --output "$f.asc" "$f"
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
done
# clean branch name to get the folder name in S3
# clean branch name to get the folder name in the object storage
- name: Get cleaned branch name
id: clean_name
env:
REF: ${{ github.ref }}
run: |
REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
echo "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: configure aws
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ secrets.AWS_REGION }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: upload binaries to s3
- name: upload binaries to cloudflare r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
- name: Install GH CLI
uses: dev-hanz-ops/install-gh-cli-action@v0.2.1
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
with:
gh-cli-version: 2.39.1
- name: create github release
run: |
gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --draft --notes-from-tag dist/release/*
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$TAG" --title "$TAG" --draft --notes-from-tag dist/release/*
container:
runs-on: namespace-profile-gitea-release-docker
@@ -77,13 +88,13 @@ jobs:
contents: read
packages: write # to publish to ghcr.io
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/metadata-action@v6
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta
with:
images: |-
@@ -96,7 +107,7 @@ jobs:
type=semver,pattern={{version}}
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- uses: docker/metadata-action@v6
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta_rootless
with:
images: |-
@@ -112,18 +123,18 @@ jobs:
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR using PAT
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build regular container image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
@@ -131,7 +142,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
- name: build rootless container image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
@@ -17,17 +17,18 @@ jobs:
permissions:
contents: read
packages: write # to publish to ghcr.io
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: actions/setup-go@v6
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
check-latest: true
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
@@ -36,43 +37,53 @@ jobs:
# xgo build
- run: make release
env:
TAGS: bindata sqlite sqlite_unlock_notify
TAGS: bindata
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: import gpg key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: sign binaries
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
run: |
for f in dist/release/*; do
echo '${{ secrets.GPGSIGN_PASSPHRASE }}' | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u ${{ steps.import_gpg.outputs.fingerprint }} --output "$f.asc" "$f"
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
done
# clean branch name to get the folder name in S3
# clean branch name to get the folder name in the object storage
- name: Get cleaned branch name
id: clean_name
env:
REF: ${{ github.ref }}
run: |
REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
echo "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: configure aws
uses: aws-actions/configure-aws-credentials@v6
with:
aws-region: ${{ secrets.AWS_REGION }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: upload binaries to s3
- name: upload binaries to cloudflare r2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: |
aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
- name: Install GH CLI
uses: dev-hanz-ops/install-gh-cli-action@v0.2.1
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
with:
gh-cli-version: 2.39.1
- name: create github release
run: |
gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --notes-from-tag dist/release/*
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$TAG" --title "$TAG" --notes-from-tag dist/release/*
container:
runs-on: namespace-profile-gitea-release-docker
@@ -80,13 +91,13 @@ jobs:
contents: read
packages: write # to publish to ghcr.io
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
- run: git fetch --unshallow --quiet --tags --force
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/metadata-action@v6
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta
with:
images: |-
@@ -103,7 +114,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- uses: docker/metadata-action@v6
- uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta_rootless
with:
images: |-
@@ -124,18 +135,18 @@ jobs:
annotations: |
org.opencontainers.image.authors="maintainers@gitea.io"
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR using PAT
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build regular container image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
@@ -143,7 +154,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
- name: build rootless container image
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/riscv64
-3
View File
@@ -55,10 +55,7 @@ cpu.out
*.log.*.gz
/gitea
/gitea-e2e
/gitea-vet
/debug
/integrations.test
/bin
/dist
+18 -5
View File
@@ -43,7 +43,7 @@ linters:
desc: use os or io instead
- pkg: golang.org/x/exp
desc: it's experimental and unreliable
- pkg: code.gitea.io/gitea/modules/git/internal
- pkg: gitea.dev/modules/git/internal
desc: do not use the internal package, use AddXxx function instead
- pkg: gopkg.in/ini.v1
desc: do not use the ini package, use gitea's config system instead
@@ -51,6 +51,14 @@ linters:
desc: do not use the go-chi cache package, use gitea's cache system
- pkg: github.com/pkg/errors
desc: use builtin errors package instead
migrations:
files:
- '**/models/migrations/**/*.go'
deny:
- pkg: gitea.dev/models$
desc: migrations must not depend on the models package
- pkg: gitea.dev/modules/structs
desc: migrations must not depend on modules/structs (API structures change over time)
nolintlint:
allow-unused: false
require-explanation: true
@@ -99,6 +107,7 @@ linters:
- -QF1008
testifylint:
disable:
- empty
- go-require
- require-error
usetesting:
@@ -158,9 +167,16 @@ issues:
max-same-issues: 0
formatters:
enable:
- gofmt
- gci
- gofumpt
settings:
gci:
custom-order: true
sections:
- standard
- prefix(gitea.dev)
- blank
- default
gofumpt:
extra-rules: true
exclusions:
@@ -170,9 +186,6 @@ formatters:
- .venv
- public
- web_src
- third_party$
- builtin$
- examples$
run:
timeout: 10m

Some files were not shown because too many files have changed in this diff Show More