Compare commits
43
Commits
4a88e7c6e8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e22dcdda80 | ||
|
|
c93dee27b3 | ||
|
|
3f7856b4aa | ||
|
|
5b0ae6be5e | ||
|
|
54eb69d1c7 | ||
|
|
0cbf267474 | ||
|
|
335a74010d | ||
|
|
4c9611a2d2 | ||
|
|
5681519b6f | ||
|
|
fd59158ed6 | ||
|
|
d9b2a4e787 | ||
|
|
c439c1b948 | ||
|
|
0fe85c67d9 | ||
|
|
09eecf5969 | ||
|
|
e444ea5936 | ||
|
|
c917e4908c | ||
|
|
ef849b085f | ||
|
|
a09f247290 | ||
|
|
3a52023082 | ||
|
|
2bd466b652 | ||
|
|
83cf9ff32f | ||
|
|
a9e538fc76 | ||
|
|
88ec8a59d8 | ||
|
|
417820544e | ||
|
|
1cba36e76e | ||
|
|
4f97fb3244 | ||
|
|
252d3418a5 | ||
|
|
96882fd8cd | ||
|
|
522010b7e6 | ||
|
|
761120b211 | ||
|
|
a04a58b792 | ||
|
|
1e973e7b33 | ||
|
|
66a502b1dd | ||
|
|
fce9ae07be | ||
|
|
a8c4a52a68 | ||
|
|
078e01c87f | ||
|
|
6bc564de59 | ||
|
|
cc8a7cf80e | ||
|
|
279df769db | ||
|
|
069b18daa3 | ||
|
|
37bd69e90e | ||
|
|
572133a941 | ||
|
|
ed721dd961 |
@@ -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
@@ -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:
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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
|
||||
];
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Executable
+231
@@ -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.
|
||||
@@ -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.
|
||||
Executable
+143
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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" ];
|
||||
|
||||
@@ -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
@@ -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; };
|
||||
|
||||
Generated
+1453
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -1,10 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
labels: [modifies/dependencies]
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 5
|
||||
-77
@@ -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
@@ -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.
|
||||
-->
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+40
-29
@@ -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
|
||||
|
||||
@@ -55,10 +55,7 @@ cpu.out
|
||||
*.log.*.gz
|
||||
|
||||
/gitea
|
||||
/gitea-e2e
|
||||
/gitea-vet
|
||||
/debug
|
||||
/integrations.test
|
||||
|
||||
/bin
|
||||
/dist
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user