Compare commits

...
15 Commits
Author SHA1 Message Date
yukkop 3f7856b4aa feat: update pz
runner nix smoke / nix label and flake smoke (push) Failing after 1m50s
2026-09-28 14:50:05 +00:00
yukkop 5b0ae6be5e feat: kanban cli
runner nix smoke / nix label and flake smoke (push) Failing after 1m20s
2026-09-28 09:25:04 +00:00
yukkop 54eb69d1c7 feat: clear unneccessary backend from kanban
runner nix smoke / nix label and flake smoke (push) Failing after 35s
2026-09-28 00:24:44 +00:00
yukkop 0cbf267474 feat: more crud for kanban
runner nix smoke / nix label and flake smoke (push) Failing after 2m6s
2026-09-28 00:08:44 +00:00
yukkop 335a74010d feat: kanban tui
runner nix smoke / nix label and flake smoke (push) Failing after 1m12s
2026-09-27 15:42:51 +00:00
yukkop 4c9611a2d2 some
runner nix smoke / nix label and flake smoke (push) Failing after 1m12s
2026-09-27 08:25:06 +00:00
yukkop 5681519b6f fix: global update
runner nix smoke / nix label and flake smoke (push) Failing after 30s
2026-09-26 22:01:43 +00:00
yukkop fd59158ed6 feat: update nixpkgs
runner nix smoke / nix label and flake smoke (push) Failing after 40s
2026-09-26 21:44:19 +00:00
yukkop d9b2a4e787 feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s
2026-09-26 21:18:24 +00:00
yukkop c439c1b948 fix: ssh extra debug
runner nix smoke / nix label and flake smoke (push) Failing after 57s
2026-09-26 19:11:41 +00:00
yukkop 0fe85c67d9 ci: staging server
runner nix smoke / nix label and flake smoke (push) Failing after 50s
2026-09-26 13:59:59 +00:00
yukkop 09eecf5969 feat: lfs
runner nix smoke / nix label and flake smoke (push) Failing after 1m59s
2026-09-24 23:15:43 +00:00
yukkop e444ea5936 fix: world-of-sosal
runner nix smoke / nix label and flake smoke (push) Failing after 59s
2026-09-23 19:26:19 +00:00
yukkop c917e4908c feat: zomboid backups
runner nix smoke / nix label and flake smoke (push) Failing after 54s
2026-09-22 17:15:18 +00:00
yukkop ef849b085f fix: wow-minecraft: finish configurations
runner nix smoke / nix label and flake smoke (push) Failing after 56s
2026-09-19 09:04:05 +00:00
3579 changed files with 122332 additions and 44530 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ keys:
- &bfs-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj - &bfs-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj
- &bfs-pol-server age1fpytf05sg9n6ywpwkmn09lhpfvgtud9h75h76jhxha475zpnasqq952rpu - &bfs-pol-server age1fpytf05sg9n6ywpwkmn09lhpfvgtud9h75h76jhxha475zpnasqq952rpu
- &bfs-new-server age17yx98qk9gzgcf2q6zhhp05p6mmtrkgz66dvyk9gqclypvlr8rersxjy5v7 - &bfs-new-server age17yx98qk9gzgcf2q6zhhp05p6mmtrkgz66dvyk9gqclypvlr8rersxjy5v7
- &neuro-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj - &neuro-server age1ak7heljpr0pjr4m0rcwxgn3sp0jjxw03lxyf33r8lcemqh2u2sgqx0aplq
- &games-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj - &games-server age15yzgmsvl3ku2w863h6gw2vpmw37m9aruv6xrj4fue6n2jpm7pyuqk9xjmj
- &hectic-lab-server age13h8twnwvgxn04l5ywtru89a6psw5d0uckr2eghxsjp88a5augvsstq5ard - &hectic-lab-server age13h8twnwvgxn04l5ywtru89a6psw5d0uckr2eghxsjp88a5augvsstq5ard
- &umbriel-bfs age1jxntjca8q2vxvf2jaal4xyvm2ae6sh62fhv897694kuzawfrk5asj00zdt - &umbriel-bfs age1jxntjca8q2vxvf2jaal4xyvm2ae6sh62fhv897694kuzawfrk5asj00zdt
+1
View File
@@ -5,6 +5,7 @@
postgres-c = import ./postgres-c.nix { inherit self system pkgs; }; postgres-c = import ./postgres-c.nix { inherit self system pkgs; };
pure-c = import ./pure-c.nix { inherit self system pkgs; }; pure-c = import ./pure-c.nix { inherit self system pkgs; };
rust = import ./rust.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; }; haskell = import ./haskell.nix { inherit self system pkgs; };
neuro = import ./neuro.nix { inherit self system pkgs; }; neuro = import ./neuro.nix { inherit self system pkgs; };
xmpp = import ./xmpp.nix { inherit self system pkgs; }; xmpp = import ./xmpp.nix { inherit self system pkgs; };
+19
View File
@@ -0,0 +1,19 @@
{
self,
pkgs,
system
}: let
rustToolchain =
if builtins.pathExists ./rust-toolchain.toml
then pkgs.pkgsBuildHost.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml
else pkgs.pkgsBuildHost.rust-bin.stable."1.81.0".default;
in
self.devShells.${system}.default
// (pkgs.mkShell {
nativeBuildInputs = [
rustToolchain
pkgs.pkg-config
pkgs.gcc
pkgs.gnumake
];
})
+2
View File
@@ -1,6 +1,8 @@
# Documentation # Documentation
- [Using the `hectic` Attic Cache](./attic-cache.md) - [Using the `hectic` Attic Cache](./attic-cache.md)
- [Minecraft incident log](./minecraft-incidents.md)
- [Project Zomboid backups](./project-zomboid-backups.md)
## Gitea runner labels ## Gitea runner labels
+101
View File
@@ -0,0 +1,101 @@
# Minecraft incident log
This file records only observed evidence, actions, and verification results.
An entity appearing in a stack trace is a trigger-path observation, not a
proven root cause.
## 2026-09-19 — WorldOfSosal crashes in Sable block-change handling
### Impact
- `minecraft-server-wowMineMap.service` terminates while a player is online.
- Public Minecraft endpoint is `store.hectic-lab.com:25568`.
- Server is intentionally stopped after the latest crash to prevent repeated
crash-save cycles while recovery is investigated.
### Observed evidence
All crash reports contain `sable@2.0.5` in
`LevelAccelerator.getBlockState`, followed by
`ArrayIndexOutOfBoundsException` where the requested section index exceeds
the world section array length of `24`.
| UTC timestamp | Crash report | Observed trigger path | Exception |
| --- | --- | --- | --- |
| 18:47:03 | `crash-2026-09-19_18.47.03-server.txt` | `EnderMan$EndermanTakeBlockGoal.tick` | index `38` / length `24` |
| 18:52:17 | `crash-2026-09-19_18.52.17-server.txt` | `GlowSquid.aiStep` → `RedStoneOreBlock.stepOn` | index `33` / length `24` |
| 19:14:46 | `crash-2026-09-19_19.14.46-server.txt` | `Skeleton.tick` → `RedStoneOreBlock.stepOn` | index `34` / length `24` |
Evidence locations on `neuro`:
```text
/srv/minecraft/wowMineMap/crash-reports/
/srv/minecraft/wowMineMap/logs/latest.log
```
### Actions performed
| UTC timestamp | Action | Result |
| --- | --- | --- |
| 17:51 | Archived current world before recovery | Archive checksum recorded |
| 18:08 | Set `randomTickSpeed=0` | Server started, but later crashed from an entity block change |
| 18:48 | Set `mobGriefing=false` | Prevented Enderman block pickup only; later crashes still occurred |
| 18:54 | Archived post-crash world | Archive checksum recorded |
| 19:00 | Moved Boss offline player NBT from `(3299.067, 142.630, 8613.742)` to `(3296, 500, 8608)` in `crafting_azeroth:azeroth` | Only `Pos` and `Dimension` changed; later crash still occurred |
| after 19:14 crash | Stopped `minecraft-server-wowMineMap.service` | Prevented further automatic crash/restart saves |
### Recovery artifacts
```text
/srv/minecraft/backups/wowMineMap-before-sable-recovery-20260919T175139Z.tar.zst
/srv/minecraft/backups/wowMineMap-after-sable-crashes-20260919T185445Z.tar.zst
/srv/minecraft/wowMineMap/world/playerdata/1c189af5-2713-3fa6-bcc4-893dfadedfa4.dat.before-relocation
```
### Conclusions supported by evidence
- Public proxy and reverse tunnel are not the failure point: server-list ping
succeeded before later in-world crashes.
- The failure is not limited to Endermen, random ticks, or one player
position.
- Sable's block-change callback is present in every captured crash.
### Not established
- Exact corrupt chunk, block, or mod data.
- Whether world data is corrupt, Sable itself is defective, or another mod is
supplying incompatible world state.
- Whether deleting any chunk, region, or Sable state would be safe.
### External research
No exact upstream match was found for Sable `2.0.5` on NeoForge `1.21.1` with
`LevelAccelerator.getBlockState` and a requested section index of `33`, `34`,
or `38` against a section array of length `24`.
Related but non-identical upstream reports:
- [Sable #776](https://github.com/ryanhcode/sable/issues/776) documents an
`ArrayIndexOutOfBoundsException` associated with unusual dimension height
bounds. This is relevant to section-coordinate handling, but is an older
version and different stack trace.
- [Sable #1087](https://github.com/ryanhcode/sable/issues/1087) documents a
`LevelAccelerator.getBlockState` recursion during block-shape processing.
The failure type differs.
- [Sable #820](https://github.com/ryanhcode/sable/issues/820) documents a
ticking-entity block-change crash. The reported downgrade to `1.1.3` helped
that distinct recursive-update failure; it is not evidence for this crash.
- [Sable #1223](https://github.com/ryanhcode/sable/issues/1223) documents a
different `ArrayIndexOutOfBoundsException` in voxel-neighborhood handling.
Its suggested Lithium setting only reduced crashes for some reporters and is
not a verified mitigation here.
Sable `2.0.4` and `2.0.5` release notes mention other block or contraption
crash fixes, but not this exception. No version upgrade or downgrade is
currently evidence-backed as a production fix.
### Next recovery step
Use a disposable full-world copy to test a supported Sable/physics integration
mitigation. Do not restart production, delete region files, or overwrite a
backup until that test gives reproducible evidence.
+55 -9
View File
@@ -1,7 +1,7 @@
# WorldOfSosal: Prism automatic updates # WorldOfSosal: Prism automatic updates
The published client entry points are: The published client entry points are:
- https://bfs.band/minecraft/ (BFS / Element host) - https://store.bfs.band/minecraft/ (BFS / Element host)
- https://store.hectic-lab.com/minecraft/world-of-sosal/ (hectic-lab) - 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 Each site provides its own Prism ZIP with that site's update URL and matching
@@ -95,8 +95,8 @@ 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 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 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 the handshake on this route. Both ends check peer liveness so stale listeners
are eventually released. Minecraft retains are eventually released. Minecraft initially used `online-mode=true`. It now uses offline mode at the
`online-mode=true`, requiring normal authenticated Minecraft accounts. owner's request; see the RCON and authentication section below.
For a temporary direct local tunnel, use: For a temporary direct local tunnel, use:
@@ -156,22 +156,22 @@ SHA-256 was verified before extraction.
## Independent BFS entry point (2026-09-19) ## Independent BFS entry point (2026-09-19)
- Server: `bfs.band:25568`; downloads: https://bfs.band/minecraft/. - Server: `wow.bfs.band`; downloads: https://store.bfs.band/minecraft/.
- BFS is `bfs.poland.xray` (91.198.166.181), the host of Element. - 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 - `minecraft-wow-tunnel-bfs` connects neuro directly to BFS. The BFS path does
not transit hectic-lab; both tunnels have independent reconnecting services. not transit hectic-lab; both tunnels have independent reconnecting services.
- Shared proxy implementation: `nixos/module/generic/minecraft-public-relay.nix`. - Shared proxy implementation: `nixos/module/generic/minecraft-public-relay.nix`.
Host settings remain in `minecraft-wow-proxy.nix` (hectic-lab) and Host settings remain in `minecraft-wow-proxy.nix` (hectic-lab) and
`minecraft-wow.nix` (BFS). Only `/minecraft/` is added to the existing BFS `minecraft-wow.nix` (BFS). A dedicated HTTPS virtual host serves `store.bfs.band`. The legacy
nginx virtual host; Element/Matrix routes remain intact. `bfs.band/minecraft/` URLs remain available for already imported instances.
- Downloaded BFS ZIP seeds `bfs.band:25568` and uses the stable manifest - Downloaded BFS ZIP seeds `wow.bfs.band` and uses the stable manifest
`https://bfs.band/minecraft/world-of-sosal/current/pack.toml`. It does not `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 redirect installation metadata to hectic-lab. Upstream mod and Java/loader
downloads still use their original providers (e.g. Modrinth, GitHub, Mojang). downloads still use their original providers (e.g. Modrinth, GitHub, Mojang).
- Existing hectic-lab instances can be migrated without reinstalling mods: - Existing hectic-lab instances can be migrated without reinstalling mods:
in Edit / Settings / Custom commands, replace only the manifest URL in in Edit / Settings / Custom commands, replace only the manifest URL in
Pre-launch command with the BFS URL above. Change the multiplayer server Pre-launch command with the BFS URL above. Change the multiplayer server
address to bfs.band:25568. New users should import the ZIP from BFS. 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 - `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 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 archive has the same SHA-256. Each host's switch is atomic; publication across
@@ -183,3 +183,49 @@ Clean installation through the BFS manifest passed: all 141 client mods and
all overrides match the source archive. A second updater run performed no all overrides match the source archive. A second updater run performed no
downloads and preserved options.txt. The public BFS login protocol reached downloads and preserved options.txt. The public BFS login protocol reached
online authentication; the earlier full GUI login used hectic-lab. online authentication; the earlier full GUI login used hectic-lab.
## BFS DNS and dedicated download site (2026-09-19)
Porkbun DNS, TTL 600:
| Type | Name | Value |
| --- | --- | --- |
| A | store.bfs.band | 91.198.166.181 |
| A | wow.bfs.band | 91.198.166.181 |
| SRV | _minecraft._tcp.wow.bfs.band | 0 0 25568 wow.bfs.band |
Players enter `wow.bfs.band` without a port in Minecraft Java. In Porkbun,
SRV Priority is `0`, and Target is `0 25568 wow.bfs.band` (weight, port, host).
The root download URL https://store.bfs.band/ redirects to the WorldOfSosal page.
The NixOS virtual host obtains and renews its HTTPS certificate automatically.
The publication script now seeds this update URL and the port-free game address.
Existing BFS instances retain working legacy update URLs; switching their
pre-launch manifest to the new store host is optional. Root bfs.band remains
the existing Element entry point.
## RCON and authentication (2026-09-19)
The WoW server now has `online-mode=false`. Account authentication is disabled;
player names can be impersonated, and offline UUIDs differ from online UUIDs.
Existing inventory/permissions may require a separate UUID migration.
RCON listens on TCP 25575 on neuro; its port is not opened in the firewall or
forwarded through the public Minecraft relays. The server-specific automatic
firewall is disabled and only game port 25567 is explicitly permitted.
A random password is stored in SOPS as `minecraft/rcon-password`, injected into
server.properties at startup with mode 0600, and is absent from the Nix store.
Start a local-only SSH tunnel and leave it running:
```sh
ssh -NT -L 127.0.0.1:25575:127.0.0.1:25575 -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 neuro
```
Retrieve the password in another terminal (do not paste it into logs):
```sh
ssh neuro cat /run/secrets/minecraft/rcon-password
```
Configure the RCON client with host `127.0.0.1`, port `25575`, and that password.
There is no RCON username. These changes apply to wowMineMap only.
+119
View File
@@ -0,0 +1,119 @@
# Project Zomboid backups
`hectic.services."project-zomboid".backup` creates local backups without stopping
or pausing the server. The default schedule is every 30 minutes. Each run:
1. sends the local RCON `save` command and waits for the configured save grace
period;
2. rsyncs `Zomboid/Saves/Multiplayer/<serverName>` and non-secret server
settings (`SandboxVars`, spawn-points, and spawn-regions) from
`Zomboid/Server` into a private staging tree;
3. waits five seconds and repeats the rsync to narrow the live-write window;
4. publishes a timestamped `tar.zst` archive; and
5. uploads it to S3 when enabled, then applies local retention.
With `backup.retentionDays = 0`, local timestamped archives are removed after a
successful S3 upload (or after local creation when S3 is disabled). A failed
upload leaves the current archive locally and the next run retries that archive
before creating a new one. Restore archives named `pre-restore` are not managed
by this cleanup.
The service lock prevents overlapping runs. Missing save or server-config paths
skip the run through systemd `ConditionPathExists` checks.
## Consistency and secrets
This is a best-effort backup. It does not stop Project Zomboid and does not use
an atomic filesystem snapshot. The RCON save command flushes the world before
copying, and the second rsync narrows the remaining live-write window, but
neither makes the filesystem copy an atomic snapshot.
Archives do not include the generated server INI, `admin-password`,
host-generated password files, or the S3 credentials file. The server INI is
generated again during service startup; provision secret-backed values separately
after a restore.
## hectic-lab
hectic-lab runs the timer every 30 minutes and stores timestamped backups in
S3 for 14 days. It keeps no regular timestamped backup archives locally:
```text
/var/lib/project-zomboid/backups/archive/
```
The existing `project-zomboid-restore.sh` helper expects the fresh backup it
creates to remain in this directory for rollback. Therefore, with local
retention set to zero, do not use the helper until it is adapted for S3-only
retention; temporarily configure positive local retention for a restore.
Check it with:
```sh
systemctl list-timers project-zomboid-backup.timer
systemctl status project-zomboid-backup.service
journalctl -u project-zomboid-backup.service
```
RCON is enabled on localhost port `27015`; the firewall does not expose this
port. The password is generated at
`/var/lib/project-zomboid/rcon-password` with mode `0600`. The server also uses
`SaveWorldEveryMinutes=15` as a periodic persistence fallback.
## Optional S3 upload
S3 upload is disabled by default. Enabling it requires `bucket`, `endpoint`,
`region`, and an absolute runtime `credentialsFile` outside `/nix/store`. The
endpoint must use HTTPS. systemd reads the environment file without executing
it; this host keeps it owned by `project-zomboid` with mode `0400`:
```sh
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
```
Set `backup.s3.prefix` to choose the object-key prefix and
`backup.s3.remoteRetentionDays` to prune old archives from that prefix. Remote
deletion runs only after a successful upload and only matches this server's
archive name prefix. Configure bucket lifecycle expiration/versioning too when
available; it remains the stronger recovery and cleanup control.
## Restore
Restoring must be done while the server is stopped so it cannot modify files
during extraction:
On hectic-lab, regular timestamped archives are retained only in S3. With
valid S3 credentials, download the chosen archive before restoring it:
```sh
aws s3 cp \
s3://backup-hectic-lab/project-zomboid/project-zomboid-servertest-<timestamp>.tar.zst \
/var/lib/project-zomboid/backups/archive/<archive>.tar.zst \
--endpoint-url https://hel1.your-objectstorage.com \
--region hel1
```
The versioned helper creates a fresh current-state backup, stops the timer and
server, validates archive paths, restores the save, and starts both services:
```sh
sudo ./docs/project-zomboid-restore.sh \
/var/lib/project-zomboid/backups/archive/<archive>.tar.zst
```
It writes a rollback archive named
`project-zomboid-<serverName>-pre-restore-<timestamp>.tar.zst` before changing
the save.
```sh
systemctl stop project-zomboid.service
tar --zstd --no-same-owner --no-same-permissions \
-xf /var/lib/project-zomboid/backups/archive/<archive>.tar.zst \
-C /var/lib/project-zomboid
chown -R project-zomboid:project-zomboid /var/lib/project-zomboid/Zomboid
systemctl start project-zomboid.service
```
Re-provision password files and secret-backed INI values before starting.
Verify the restored save and server name before allowing players to reconnect.
+143
View File
@@ -0,0 +1,143 @@
#!/bin/sh
set -eu
SERVER_NAME=${SERVER_NAME:-servertest}
DATA_DIR=${DATA_DIR:-/var/lib/project-zomboid}
ARCHIVE=${1:-}
usage() {
printf '%s\n' "Usage: $0 /path/to/project-zomboid-${SERVER_NAME}-<timestamp>.tar.zst"
printf '%s\n' "Environment: SERVER_NAME, DATA_DIR"
}
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' 'Run as root.' >&2
exit 1
fi
if [ -z "$ARCHIVE" ]; then
usage >&2
exit 2
fi
if [ ! -r "$ARCHIVE" ]; then
printf 'Backup archive is not readable: %s\n' "$ARCHIVE" >&2
exit 1
fi
ARCHIVE_DIR="$DATA_DIR/backups/archive"
SAVE_DIR="$DATA_DIR/Zomboid/Saves/Multiplayer/$SERVER_NAME"
SERVER_DIR="$DATA_DIR/Zomboid/Server"
TMP_LIST=$(mktemp)
ROLLBACK_ARCHIVE=''
SERVER_STOPPED=0
RESTORE_SUCCEEDED=0
cleanup() {
rm -f "$TMP_LIST"
}
on_exit() {
status=$?
if [ "$status" -ne 0 ] && [ "$SERVER_STOPPED" -eq 1 ] \
&& [ "$RESTORE_SUCCEEDED" -eq 0 ] && [ -n "$ROLLBACK_ARCHIVE" ]; then
set +e
rm -rf "$SAVE_DIR"
rm -f \
"$SERVER_DIR/${SERVER_NAME}_SandboxVars.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnpoints.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnregions.lua"
tar --zstd --no-same-owner --no-same-permissions \
-xpf "$ROLLBACK_ARCHIVE" -C "$DATA_DIR"
chown -R project-zomboid:project-zomboid "$SAVE_DIR" "$SERVER_DIR"
systemctl start project-zomboid.service
systemctl start project-zomboid-backup.timer
printf '%s\n' "Restore failed; current state restored from $ROLLBACK_ARCHIVE" >&2
fi
cleanup
exit "$status"
}
trap on_exit EXIT
if ! tar --zstd -tf "$ARCHIVE" >"$TMP_LIST"; then
printf 'Archive integrity check failed: %s\n' "$ARCHIVE" >&2
exit 1
fi
while IFS= read -r member; do
case "$member" in
Zomboid/*) ;;
*)
printf 'Unsafe archive member: %s\n' "$member" >&2
exit 1
;;
esac
case "$member" in
/*|*../*)
printf 'Path traversal member: %s\n' "$member" >&2
exit 1
;;
esac
done <"$TMP_LIST"
if ! systemctl start project-zomboid-backup.service; then
printf '%s\n' 'Could not create fresh backup of current state.' >&2
exit 1
fi
CURRENT_ARCHIVE=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f \
-name "project-zomboid-$SERVER_NAME-*.tar.zst" \
-printf '%T@ %p\n' | sort -nr | awk 'NR == 1 {sub(/^[^ ]* /, ""); print}')
if [ -z "$CURRENT_ARCHIVE" ]; then
printf '%s\n' 'Fresh current-state backup was not found.' >&2
exit 1
fi
stamp=$(date -u +%Y%m%dT%H%M%SZ)
ROLLBACK_ARCHIVE="$ARCHIVE_DIR/project-zomboid-$SERVER_NAME-pre-restore-$stamp.tar.zst"
cp --reflink=auto "$CURRENT_ARCHIVE" "$ROLLBACK_ARCHIVE" 2>/dev/null \
|| cp "$CURRENT_ARCHIVE" "$ROLLBACK_ARCHIVE"
chmod 0600 "$ROLLBACK_ARCHIVE"
chown project-zomboid:project-zomboid "$ROLLBACK_ARCHIVE"
systemctl stop project-zomboid-backup.timer
systemctl stop project-zomboid.service
SERVER_STOPPED=1
if [ "$(systemctl show project-zomboid --property=ActiveState --value)" != inactive ]; then
printf '%s\n' 'Project Zomboid did not stop; refusing to restore.' >&2
exit 1
fi
rm -rf "$SAVE_DIR"
rm -f \
"$SERVER_DIR/${SERVER_NAME}_SandboxVars.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnpoints.lua" \
"$SERVER_DIR/${SERVER_NAME}_spawnregions.lua"
tar --zstd --no-same-owner --no-same-permissions \
-xpf "$ARCHIVE" -C "$DATA_DIR"
chown -R project-zomboid:project-zomboid "$SAVE_DIR" "$SERVER_DIR"
systemctl start project-zomboid.service
started=0
for _ in $(seq 1 90); do
if [ "$(systemctl show project-zomboid --property=ActiveState --value)" = active ] \
&& [ "$(systemctl show project-zomboid --property=SubState --value)" = running ]; then
started=1
break
fi
sleep 2
done
if [ "$started" -ne 1 ]; then
printf 'Restore completed, but service did not become healthy. Rollback: %s\n' \
"$ROLLBACK_ARCHIVE" >&2
exit 1
fi
systemctl start project-zomboid-backup.timer
RESTORE_SUCCEEDED=1
printf 'Restore completed.\n'
printf 'Rollback archive: %s\n' "$ROLLBACK_ARCHIVE"
Generated
+17
View File
@@ -915,6 +915,22 @@
"type": "github" "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": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1779796641, "lastModified": 1779796641,
@@ -1018,6 +1034,7 @@
"nixos-mailserver": "nixos-mailserver", "nixos-mailserver": "nixos-mailserver",
"nixos-wsl": "nixos-wsl", "nixos-wsl": "nixos-wsl",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_2",
"nixpkgs-gitea": "nixpkgs-gitea",
"nixvim": "nixvim", "nixvim": "nixvim",
"rust-overlay": "rust-overlay_2", "rust-overlay": "rust-overlay_2",
"sops-nix": "sops-nix" "sops-nix": "sops-nix"
+1
View File
@@ -13,6 +13,7 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
nixpkgs-gitea.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay = { rust-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs = { inputs = {
@@ -37,6 +37,209 @@
) cfg.sandboxProperties; ) cfg.sandboxProperties;
zomboidDir = "${cfg.dataDir}/Zomboid"; zomboidDir = "${cfg.dataDir}/Zomboid";
adminPasswordFile = "${cfg.dataDir}/admin-password"; 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" '' startScript = pkgs.writeShellScript "project-zomboid-start" ''
admin_password=$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg adminPasswordFile}) admin_password=$(${pkgs.coreutils}/bin/cat ${lib.escapeShellArg adminPasswordFile})
exec ${pkgs.steam-run}/bin/steam-run \ exec ${pkgs.steam-run}/bin/steam-run \
@@ -131,9 +334,149 @@ in {
default = true; default = true;
description = "Open the Project Zomboid UDP ports in the firewall."; 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 { 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.groups.project-zomboid = { };
users.users.project-zomboid = { users.users.project-zomboid = {
isSystemUser = true; isSystemUser = true;
@@ -145,6 +488,11 @@ in {
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 project-zomboid project-zomboid - -" "d ${cfg.dataDir} 0750 project-zomboid project-zomboid - -"
"d ${cfg.installDir} 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 = { systemd.services.project-zomboid = {
@@ -160,6 +508,22 @@ in {
umask 077 umask 077
${pkgs.openssl}/bin/openssl rand -base64 32 > ${lib.escapeShellArg adminPasswordFile} ${pkgs.openssl}/bin/openssl rand -base64 32 > ${lib.escapeShellArg adminPasswordFile}
fi 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 \ ${pkgs.steamcmd}/bin/steamcmd \
+force_install_dir ${lib.escapeShellArg cfg.installDir} \ +force_install_dir ${lib.escapeShellArg cfg.installDir} \
+login anonymous \ +login anonymous \
@@ -176,6 +540,12 @@ in {
) configLines} ) configLines}
${lib.optionalString (cfg.serverPropertiesFile != null) ${lib.optionalString (cfg.serverPropertiesFile != null)
"${pkgs.coreutils}/bin/cat ${lib.escapeShellArg cfg.serverPropertiesFile};"} "${pkgs.coreutils}/bin/cat ${lib.escapeShellArg cfg.serverPropertiesFile};"}
${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.escapeShellArg "${zomboidDir}/Server/${cfg.serverName}.ini"}
${lib.optionalString (cfg.sandboxProperties != { }) '' ${lib.optionalString (cfg.sandboxProperties != { }) ''
{ {
@@ -186,6 +556,10 @@ in {
${pkgs.coreutils}/bin/printf '%s\n' '};'; ${pkgs.coreutils}/bin/printf '%s\n' '};';
} > ${lib.escapeShellArg "${zomboidDir}/Server/${cfg.serverName}_SandboxVars.lua"} } > ${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 = { serviceConfig = {
@@ -205,6 +579,34 @@ in {
}; };
}; };
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 [ networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall [
cfg.port cfg.port
cfg.udpPort cfg.udpPort
@@ -6,6 +6,20 @@
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKNWWegOVTOF3EOmam32iP7sMybULMTxsXuC+cEGITQ8 minecraft-wow-relay"; publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKNWWegOVTOF3EOmam32iP7sMybULMTxsXuC+cEGITQ8 minecraft-wow-relay";
}; };
systemd.tmpfiles.rules = [ "d /var/www/store/minecraft/world-of-sosal 0755 root root -" ]; 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 = { services.nginx.virtualHosts."bfs.band".locations = {
"= /minecraft".return = "302 /minecraft/world-of-sosal/"; "= /minecraft".return = "302 /minecraft/world-of-sosal/";
"= /minecraft/".return = "302 /minecraft/world-of-sosal/"; "= /minecraft/".return = "302 /minecraft/world-of-sosal/";
@@ -17,7 +17,7 @@ let
UsePAM yes UsePAM yes
AuthenticationMethods publickey AuthenticationMethods publickey
AuthorizedKeysFile %h/.ssh/authorized_keys /etc/ssh/authorized_keys.d/%u AuthorizedKeysFile %h/.ssh/authorized_keys /etc/ssh/authorized_keys.d/%u
LogLevel DEBUG3 LogLevel INFO
VersionAddendum none VersionAddendum none
HostKeyAlgorithms rsa-sha2-512,rsa-sha2-256,ssh-ed25519 HostKeyAlgorithms rsa-sha2-512,rsa-sha2-256,ssh-ed25519
@@ -65,7 +65,8 @@ in
Type = "simple"; Type = "simple";
StateDirectory = "experimental-sshd"; StateDirectory = "experimental-sshd";
RuntimeDirectory = "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 = '' preStart = ''
${pkgs.openssh}/bin/sshd -t -f /etc/ssh/experimental-sshd_config ${pkgs.openssh}/bin/sshd -t -f /etc/ssh/experimental-sshd_config
+81 -45
View File
@@ -14,7 +14,7 @@ with builtins;
with lib; with lib;
let let
domain = "hectic-lab.com"; domain = "hectic-lab.com";
sshPort = 22; giteaSshPort = 22223;
mailUserNames = [ mailUserNames = [
"security" "security"
"founders" "founders"
@@ -103,11 +103,25 @@ in {
}; };
services."project-zomboid" = { services."project-zomboid" = {
enable = true; enable = true;
memory = "3g"; memory = "4g";
serverName = "servertest"; serverName = "servertest";
serverPropertiesFile = /var/lib/project-zomboid/server-password.ini; 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 = { serverProperties = {
Map = "vehicle_interior_arcadia75;Muldraugh, KY"; Map = "Muldraugh, KY";
SaveWorldEveryMinutes = 15;
DoLuaChecksum = false; DoLuaChecksum = false;
Public = true; Public = true;
AntiCheatSafety = 4; AntiCheatSafety = 4;
@@ -127,54 +141,42 @@ in {
AntiCheatNoClip = 4; AntiCheatNoClip = 4;
AntiCheatServerCustomization = 4; AntiCheatServerCustomization = 4;
}; };
workshopItems = [
"3676456221" # Lua Digital Watch Framework
"3600401184" # Realistic Temperature Mod
];
mods = [
"\\LuaDigitalWatchUI"
"\\RC_RealisticColdMod"
];
sandboxProperties = { sandboxProperties = {
StartMonth = 12; StartMonth = 12;
StartDay = 1; StartDay = 1;
WaterShut = 3; WaterShut = 3;
WaterShutModifier = 60; WaterShutModifier = 150;
ElecShut = 3; ElecShut = 3;
ElecShutModifier = 60; ElecShutModifier = 150;
MinutesPerPage = 0.5; 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 = { ZombieLore = {
Transmission = 4; Transmission = 4;
Mortality = 7; Mortality = 7;
Speed = 2;
SprinterPercentage = 0;
Strength = 2;
Cognition = 2;
DoorOpeningPercentage = 10;
}; };
}; };
workshopItems = [
"3773972040" # Arcadia RV Interiors B42.20
"2210760610" # Cryogenic Winter +Easy/Hard Modes
"3676456221" # Lua Digital Watch Framework
"3600401184" # Realistic Temperature Mod
"3387824513" # Material Weight Reducer
"3413150945" # More Damaged Objects
"3512708849" # Shotgun Trajectory
"3401576145" # Firearm Models: Redux
"3401134276" # Vanilla Gear Expanded
"2956146279" # Rain Cleans Blood
"3693258802" # Tactical Hold
"3394588830" # Simple Flashlight on Belt
"2684285534" # Spongie's Clothing
"2812326159" # Spongie's Open Jackets
];
mods = [
"\\ArcadiaRVInterior_B42_MP"
"\\ArcadiaRVInterior_B42_Vanilla"
"\\CryogenicWinter2NormalMode"
"\\LuaDigitalWatchUI"
"\\RC_RealisticColdMod"
"\\Material Weight Reducer"
"\\Ammunition Weight Reducer"
"\\MoreDamagedObjects"
"\\ShotgunTrajectory"
"\\FMR"
"\\VanillaGearExpanded"
"\\RainCleansBlood"
"\\TacHold Complete"
"\\LightOnBelt"
"\\SpnCloth"
"\\SpnOpenClothBase"
"\\SpnOpenCloth"
];
}; };
services.p4d = { services.p4d = {
enable = true; enable = true;
@@ -265,12 +267,37 @@ in {
"jwt-secret" "jwt-secret"
"s3-access-key" "s3-access-key"
"s3-secret-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 '' systemd.services.project-zomboid.preStart = lib.mkBefore ''
password_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password"} password_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password"}
properties_file=${lib.escapeShellArg "/var/lib/project-zomboid/server-password.ini"} 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 if [ ! -s "$password_file" ] || ! ${pkgs.gnugrep}/bin/grep -Eq '^[0-9a-f]{48}$' "$password_file"; then
umask 077 umask 077
@@ -315,7 +342,7 @@ in {
]; ];
}; };
services.openssh.ports = [ sshPort ]; services.openssh.ports = [ giteaSshPort ];
services.mailserver = { services.mailserver = {
enable = true; enable = true;
@@ -337,7 +364,7 @@ in {
networking.firewall = { networking.firewall = {
allowedTCPPorts = [ allowedTCPPorts = [
sshPort # ssh giteaSshPort # gitea ssh
80 80
443 443
3306 # mysql 3306 # mysql
@@ -507,6 +534,9 @@ in {
extraConfig = '' extraConfig = ''
proxy_pass http://127.0.0.1:11011/; proxy_pass http://127.0.0.1:11011/;
proxy_redirect off; 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;
''; '';
}; };
}; };
@@ -516,15 +546,21 @@ in {
gitea = { gitea = {
enable = true; enable = true;
package = pkgs.hectic.gitea-heatmap; package = pkgs.hectic.gitea-heatmap;
settings.service.DISABLE_REGISTRATION = false; # Keep LFS storage limited to accounts provisioned by administrators.
settings.service.DISABLE_REGISTRATION = true;
settings.session.COOKIE_SECURE = true;
settings.actions.ENABLED = true; settings.actions.ENABLED = true;
# Long CUDA builds must not hit Gitea's default three-hour task watchdog. # Long CUDA builds must not hit Gitea's default three-hour task watchdog.
settings.actions.ENDLESS_TASK_TIMEOUT = "8h"; settings.actions.ENDLESS_TASK_TIMEOUT = "8h";
settings.server = { settings.server = {
HTTP_ADDR = "127.0.0.1";
HTTP_PORT = 11011; HTTP_PORT = 11011;
SSH_PORT = sshPort; ROOT_URL = "https://gitea.${domain}/";
SSH_PORT = giteaSshPort;
SSH_DOMAIN = "hectic-lab.com"; SSH_DOMAIN = "hectic-lab.com";
}; };
lfs.enable = true;
settings.lfs.LFS_MAX_FILE_SIZE = 536870912;
database = { database = {
createDatabase = true; createDatabase = true;
type = "postgres"; type = "postgres";
+2
View File
@@ -19,6 +19,8 @@ in self.lib.nixpkgs-lib.nixosSystem {
self.lib.cudaUnfreePredicate pkg || builtins.elem (self.lib.nixpkgs-lib.getName pkg) [ self.lib.cudaUnfreePredicate pkg || builtins.elem (self.lib.nixpkgs-lib.getName pkg) [
"minecraft-server" "minecraft-server"
"neoforge" "neoforge"
"steamcmd"
"steam-unwrapped"
"nvidia-x11" "nvidia-x11"
]; ];
@@ -13,10 +13,10 @@
serverName = "wowMineMap"; serverName = "wowMineMap";
remoteHost = "u664722.your-storagebox.de"; remoteHost = "u664722.your-storagebox.de";
remoteUser = "u664722"; remoteUser = "u664722";
remotePath = "minecraft/pack/WorldOfSosal.mrpack"; remotePath = "minecraft/pack/WorldOfSosal-v3.mrpack";
archiveName = "WorldOfSosal.mrpack"; archiveName = "WorldOfSosal.mrpack";
cacheDir = "/var/lib/minecraft-modpacks/worldOfSosal"; cacheDir = "/var/lib/minecraft-modpacks/worldOfSosal";
archiveSha256 = "f8c18acb9208e4592725632ae50dab4f9c308483b34fd43a6507c74fdbf8169f"; archiveSha256 = "f97cf251b14f40590e97e7b39e8a8ec43dacfce6da1b02357d15e0eee10d3ade";
expectedDependencies = { expectedDependencies = {
minecraft = "1.21.1"; minecraft = "1.21.1";
neoforge = "21.1.250"; neoforge = "21.1.250";
+23 -2
View File
@@ -22,9 +22,28 @@
hostPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5EB5p/5Hp3hGW1oHok+PIOH9Pbn7cnUiGmUEBrCVjnAw+HrKyN8bYVV0dIGllswYXwkG/+bgiBlE6IVIBAq+JwVWu1Sss3KarHY3OvFJUXZoZyRRg/Gc/+LRCE7lyKpwWQ70dbelGRyyJFH36eNv6ySXoUYtGkwlU5IVaHPApOxe4LHPZa/qhSRbPo2hwoh0orCtgejRebNtW5nlx00DNFgsvn8Svz2cIYLxsPVzKgUxs8Zxsxgn+Q/UvR7uq4AbAhyBMLxv7DjJ1pc7PJocuTno2Rw9uMZi1gkjbnmiOh6TTXIEWbnroyIhwc8555uto9melEUmWNQ+C+PwAK+MPw=="; 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 = { 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; enable = true;
jvmOpts = "-Xmx8G -Xms2G"; jvmOpts = "-Xmx24G -Xms2G";
# WorldOfSosal client and server use the same pinned NeoForge. # WorldOfSosal client and server use the same pinned NeoForge.
package = pkgs.minecraftServers.neoforge-1_21_1.override ( package = pkgs.minecraftServers.neoforge-1_21_1.override (
builtins.fromJSON (builtins.readFile ./neoforge-21.1.250.json) builtins.fromJSON (builtins.readFile ./neoforge-21.1.250.json)
@@ -33,7 +52,9 @@
serverProperties = { serverProperties = {
server-port = 25567; server-port = 25567;
difficulty = "hard"; difficulty = "hard";
online-mode = true; online-mode = false;
enable-rcon = true;
"rcon.port" = 25575;
view-distance = 12; view-distance = 12;
simulation-distance = 8; simulation-distance = 8;
motd = "WorldOfSosal — World of Warcraft"; motd = "WorldOfSosal — World of Warcraft";
+65 -1
View File
@@ -139,7 +139,7 @@ in {
}; };
services.nginx = { services.nginx = {
enable = true; enable = false;
virtualHosts."bfs.band" = let virtualHosts."bfs.band" = let
site = pkgs.runCommand "bfs-band-site" {} '' site = pkgs.runCommand "bfs-band-site" {} ''
mkdir -p $out mkdir -p $out
@@ -224,6 +224,70 @@ in {
archetype.dev.enable = true; 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 = { sops = {
gnupg.sshKeyPaths = [ ]; gnupg.sshKeyPaths = [ ];
age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
+14
View File
@@ -6,6 +6,20 @@ in final: prev: (
legacyPackages = self.legacyPackages.${prev.stdenv.hostPlatform.system}; legacyPackages = self.legacyPackages.${prev.stdenv.hostPlatform.system};
in { in {
hectic = packages // legacyPackages; 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 // { postgresql_17 = prev.postgresql_17 // {pkgs = prev.postgresql_17.pkgs // {
http = packages.pg-17-ext-http; http = packages.pg-17-ext-http;
pg_smtp_client = packages.pg-17-ext-smtp-client; pg_smtp_client = packages.pg-17-ext-smtp-client;
+9 -2
View File
@@ -1,4 +1,8 @@
{ self, pkgs, inputs, ... }: let { self, pkgs, inputs, system, ... }: let
giteaPkgs = import inputs.nixpkgs-gitea {
inherit system;
config = pkgs.config;
};
rust = { rust = {
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.pkgsBuildHost.rust-bin.stable."1.81.0".default pkgs.pkgsBuildHost.rust-bin.stable."1.81.0".default
@@ -135,7 +139,9 @@ in {
c-hectic = pkgs.callPackage ./c/hectic/default.nix {}; c-hectic = pkgs.callPackage ./c/hectic/default.nix {};
watch = pkgs.callPackage ./c/watch/default.nix {}; watch = pkgs.callPackage ./c/watch/default.nix {};
support-bot = pkgs.callPackage ./support-bot {}; 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-nix-image = pkgs.callPackage ./gitea-runner-nix-image {};
gitea-runner-controller = pkgs.callPackage ./gitea-runner-controller {}; gitea-runner-controller = pkgs.callPackage ./gitea-runner-controller {};
nix-derivation-hash = pkgs.callPackage ./nix-derivation-hash {}; nix-derivation-hash = pkgs.callPackage ./nix-derivation-hash {};
@@ -143,6 +149,7 @@ in {
deploy = pkgs.callPackage ./deploy { inherit inputs; }; deploy = pkgs.callPackage ./deploy { inherit inputs; };
element-web = pkgs.callPackage ./element-web {}; element-web = pkgs.callPackage ./element-web {};
shellplot = pkgs.callPackage ./shellplot {}; shellplot = pkgs.callPackage ./shellplot {};
gitea-kanban-tui = pkgs.callPackage ./gitea-kanban-tui rust.commonArgs;
which-country-rs = pkgs.callPackage ./which-country-rs {}; which-country-rs = pkgs.callPackage ./which-country-rs {};
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {}; onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
migrator = pkgs.callPackage ./migrator { inherit self; }; migrator = pkgs.callPackage ./migrator { inherit self; };
+1453
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "gitea-kanban-tui"
version = "0.1.0"
edition = "2021"
description = "Native-project Gitea Kanban terminal interface"
license = "MIT"
[dependencies]
clap = { version = "=4.5.20", features = ["derive"] }
crossterm = "=0.28.1"
encoding_rs = "=0.8.35"
indexmap = "=2.6.0"
instability = "=0.3.2"
ratatui = "=0.29.0"
reqwest = { version = "=0.11.27", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "=1.0.210", features = ["derive"] }
serde_json = "=1.0.128"
unicode-segmentation = "=1.12.0"
url = "=2.5.2"
+85
View File
@@ -0,0 +1,85 @@
# 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
```
`delete` requires repository admin permission. `move` takes global issue and
project-column IDs.
Native mode reads `/projects`, project columns, and each column's issues. Moves
use the issue's global API `id` and destination column `id`; optional sorting is
supported by the server API. Project names are exact, case-sensitive matches;
use `--project-id` when duplicate names exist. Empty native boards render
normally. `n` creates an issue assigned to the selected project, `e` edits the
focused issue title/body, and `d` deletes it after confirmation. In the editor,
`Enter` switches from title to body, `Tab` switches fields, `Ctrl-S` saves, and
`Esc` cancels. Closed projects, archived repositories, disabled Projects units,
unassigned issues, and cross-repository IDs are rejected by the server.
## Development
```sh
nix develop .#ratatui
cargo fmt --manifest-path package/gitea-kanban-tui/Cargo.toml -- --check
cargo clippy --manifest-path package/gitea-kanban-tui/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path package/gitea-kanban-tui/Cargo.toml
nix build .#gitea-kanban-tui
```
+32
View File
@@ -0,0 +1,32 @@
{
cargoToml,
lib,
nativeBuildInputs,
pkgs,
...
}: let
cargo = cargoToml ./Cargo.toml;
in
pkgs.rustPlatform.buildRustPackage {
pname = cargo.package.name;
version = cargo.package.version;
src = ./.;
inherit nativeBuildInputs;
cargoLock.lockFile = ./Cargo.lock;
doCheck = true;
postBuild = ''
cargo build --release --offline --bin gitea-kanban
'';
postInstall = ''
install -Dm755 target/*/release/gitea-kanban $out/bin/gitea-kanban
'';
meta = {
description = cargo.package.description;
license = lib.licenses.mit;
mainProgram = "gitea-kanban-tui";
};
}
+542
View File
@@ -0,0 +1,542 @@
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::{
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>;
}
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 check_response(response: Response, operation: &str) -> Result<Response, ApiError> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let guidance = match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
" Check token validity and repository issue permissions."
}
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => {
" Check repository owner/name and whether this Gitea version supports native project APIs."
}
_ => "",
};
Err(ApiError(format!(
"Gitea API failed while {operation} ({status}).{guidance}"
)))
}
fn require_secure_transport(url: &Url) -> Result<(), ApiError> {
if url.scheme() == "https" {
return Ok(());
}
let loopback = url
.host_str()
.and_then(|host| {
host.trim_start_matches('[')
.trim_end_matches(']')
.parse::<IpAddr>()
.ok()
})
.is_some_and(|address| address.is_loopback());
if url.scheme() == "http" && loopback {
return Ok(());
}
Err(ApiError(
"Gitea URL must use HTTPS to protect the API token; plain HTTP is allowed only for loopback development"
.to_owned(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc::{self, Receiver};
use std::thread;
fn config(base_url: &str) -> Config {
Config {
base_url: base_url.to_owned(),
token: "secret".to_owned(),
owner: "owner name".to_owned(),
repo: "repo/name".to_owned(),
project: Some("Kanban".to_owned()),
project_id: None,
}
}
fn mock_server(responses: Vec<&'static str>) -> (String, Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
let address = listener.local_addr().expect("mock address");
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("accept request");
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer).expect("read request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
let header_end = request
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|position| position + 4);
if let Some(header_end) = header_end {
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length: ")?
.parse::<usize>()
.ok()
})
.unwrap_or(0);
if request.len() >= header_end + content_length {
break;
}
}
}
let _ = sender.send(String::from_utf8(request).expect("UTF-8 request"));
stream
.write_all(response.as_bytes())
.expect("write response");
}
});
(format!("http://{address}"), receiver)
}
#[test]
fn preserves_base_path_and_encodes_repository_segments() {
let client = GiteaClient::new(&config("https://gitea.example/subpath"))
.expect("client should build");
assert_eq!(
client.endpoint("projects").expect("endpoint").as_str(),
"https://gitea.example/subpath/api/v1/repos/owner%20name/repo%2Fname/projects"
);
}
#[test]
fn rejects_remote_plain_http_but_allows_loopback() {
assert!(GiteaClient::new(&config("http://gitea.example")).is_err());
assert!(GiteaClient::new(&config("http://localhost:3000")).is_err());
assert!(GiteaClient::new(&config("http://127.0.0.1:3000")).is_ok());
assert!(GiteaClient::new(&config("http://[::1]:3000")).is_ok());
}
#[test]
fn resolves_exact_project_name_and_rejects_ambiguity() {
let projects = vec![
Project {
id: 1,
title: "Kanban".to_owned(),
is_closed: false,
},
Project {
id: 2,
title: "kanban".to_owned(),
is_closed: false,
},
];
assert_eq!(
resolve_project(&projects, Some("Kanban"), None)
.expect("match")
.id,
1
);
assert!(resolve_project(&projects, Some("Missing"), None).is_err());
assert_eq!(
resolve_project(&projects, None, Some(2)).expect("id").title,
"kanban"
);
}
#[test]
fn lists_projects_with_pagination_and_reports_unsupported_api() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":4,\"title\":\"Kanban\",\"is_closed\":false}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[]",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let projects = client.list_projects().expect("projects");
assert_eq!(projects[0].title, "Kanban");
let first = requests.recv().expect("first request");
let second = requests.recv().expect("second request");
assert!(first.starts_with("GET /api/v1/repos/owner%20name/repo%2Fname/projects?"));
assert!(first.contains("state=all"));
assert!(first.contains("page=1"));
assert!(second.contains("page=2"));
assert!(first.contains("authorization: token secret"));
let (base_url, _) = mock_server(vec![
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"message\":\"projects unavailable secret\"}",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let error = client
.list_project_columns(4)
.expect_err("unsupported API must fail");
assert!(error.to_string().contains("404 Not Found"));
assert!(!error.to_string().contains("secret"));
}
#[test]
fn sends_global_issue_move_payload() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
client
.move_project_issue(
4,
99,
&MoveProjectIssuePayload {
column_id: 12,
sorting: Some(3),
},
)
.expect("move");
let request = requests.recv().expect("request");
assert!(request.starts_with(
"POST /api/v1/repos/owner%20name/repo%2Fname/projects/4/issues/99/move HTTP/1.1"
));
assert!(request.ends_with("{\"column_id\":12,\"sorting\":3}"));
}
#[test]
fn paginates_native_column_issues() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[{\"id\":99,\"number\":7,\"title\":\"Fix\"}]",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n[]",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let issues = client
.list_project_column_issues(4, 12)
.expect("column issues");
assert_eq!(issues[0].id, 99);
assert!(requests.recv().expect("page one").contains("page=1"));
assert!(requests.recv().expect("page two").contains("page=2"));
}
#[test]
fn creates_edits_and_deletes_issues() {
let (base_url, requests) = mock_server(vec![
"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"New\"}",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":99,\"number\":7,\"title\":\"Updated\"}",
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
]);
let client = GiteaClient::new(&config(&base_url)).expect("client");
let created = client
.create_issue(&CreateIssuePayload {
title: "New".to_owned(),
body: "Details".to_owned(),
projects: vec![4],
})
.expect("create");
assert_eq!(created.number, 7);
let edited = client
.edit_issue(
7,
&EditIssuePayload {
title: "Updated".to_owned(),
body: "Changed".to_owned(),
},
)
.expect("edit");
assert_eq!(edited.title, "Updated");
client.delete_issue(7).expect("delete");
assert!(requests
.recv()
.expect("create request")
.ends_with("{\"title\":\"New\",\"body\":\"Details\",\"projects\":[4]}"));
assert!(requests
.recv()
.expect("edit request")
.starts_with("PATCH /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1"));
assert!(requests
.recv()
.expect("delete request")
.starts_with("DELETE /api/v1/repos/owner%20name/repo%2Fname/issues/7 HTTP/1.1"));
}
}
+502
View File
@@ -0,0 +1,502 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::{
ColumnSpec, EditIssuePayload, Issue, MoveProjectIssuePayload, Project, ProjectColumn,
};
#[derive(Clone, Debug)]
pub struct Column {
pub spec: ColumnSpec,
pub cards: Vec<Issue>,
}
pub struct App {
pub board_title: String,
pub columns: Vec<Column>,
pub focused_column: usize,
pub focused_cards: Vec<usize>,
pub status: String,
pub show_help: bool,
pub editor: Option<EditorState>,
project_id: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EditorMode {
Create { project_id: u64 },
Edit { issue_number: u64 },
Delete { issue_number: u64, title: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditorField {
Title,
Body,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorState {
pub mode: EditorMode,
pub field: EditorField,
pub title: String,
pub body: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IssueAction {
Create {
project_id: u64,
title: String,
body: String,
},
Edit {
issue_number: u64,
payload: EditIssuePayload,
},
Delete {
issue_number: u64,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum MoveAction {
Project {
issue_id: u64,
project_id: u64,
payload: MoveProjectIssuePayload,
},
}
#[derive(Debug, PartialEq, Eq)]
pub struct MoveRequest {
pub source: usize,
pub target: usize,
pub action: MoveAction,
}
impl App {
pub fn new_project(
project: Project,
project_columns: Vec<ProjectColumn>,
issues_by_column: Vec<Vec<Issue>>,
) -> Result<Self, String> {
if project_columns.is_empty() {
return Err(format!("project '{}' has no columns", project.title));
}
if project_columns.len() != issues_by_column.len() {
return Err("project columns and issue lists do not match".to_owned());
}
let columns = project_columns
.into_iter()
.zip(issues_by_column)
.map(|(column, cards)| Column {
spec: ColumnSpec {
id: column.id,
title: column.title,
},
cards,
})
.collect::<Vec<_>>();
let focused_column = columns
.iter()
.position(|column| !column.cards.is_empty())
.unwrap_or(0);
let focused_cards = vec![0; columns.len()];
Ok(Self {
board_title: format!("{} (project {})", project.title, project.id),
columns,
focused_column,
focused_cards,
status: "Ready".to_owned(),
show_help: false,
editor: None,
project_id: project.id,
})
}
pub fn focus_left(&mut self) {
self.focused_column = self.focused_column.saturating_sub(1);
}
pub fn focus_right(&mut self) {
self.focused_column = (self.focused_column + 1).min(self.columns.len() - 1);
}
pub fn focus_up(&mut self) {
let selected = &mut self.focused_cards[self.focused_column];
*selected = selected.saturating_sub(1);
}
pub fn focus_down(&mut self) {
let column = &self.columns[self.focused_column];
if !column.cards.is_empty() {
let selected = &mut self.focused_cards[self.focused_column];
*selected = (*selected + 1).min(column.cards.len() - 1);
}
}
pub fn focused_card(&self) -> Option<&Issue> {
self.columns[self.focused_column]
.cards
.get(self.focused_cards[self.focused_column])
}
pub fn prepare_move(&self, offset: isize) -> Option<MoveRequest> {
let target = self.focused_column.checked_add_signed(offset)?;
if target >= self.columns.len() {
return None;
}
let issue = self.focused_card()?;
let action = MoveAction::Project {
issue_id: issue.id,
project_id: self.project_id,
payload: MoveProjectIssuePayload {
column_id: self.columns[target].spec.id,
sorting: None,
},
};
Some(MoveRequest {
source: self.focused_column,
target,
action,
})
}
pub fn apply_move(&mut self, request: MoveRequest) {
let selected = self.focused_cards[request.source];
let issue = self.columns[request.source].cards.remove(selected);
let issue_number = issue.number;
self.columns[request.target].cards.push(issue);
self.focused_cards[request.source] =
selected.min(self.columns[request.source].cards.len().saturating_sub(1));
self.focused_cards[request.target] = self.columns[request.target].cards.len() - 1;
self.focused_column = request.target;
self.status = format!("Moved issue #{issue_number}");
}
pub fn begin_create(&mut self) {
self.editor = Some(EditorState {
mode: EditorMode::Create {
project_id: self.project_id,
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
});
}
pub fn begin_edit(&mut self) {
let Some(issue) = self.focused_card().cloned() else {
self.status = "No issue selected".to_owned();
return;
};
self.editor = Some(EditorState {
mode: EditorMode::Edit {
issue_number: issue.number,
},
field: EditorField::Title,
title: issue.title,
body: issue.body.unwrap_or_default(),
});
}
pub fn begin_delete(&mut self) {
let Some(issue) = self.focused_card() else {
self.status = "No issue selected".to_owned();
return;
};
self.editor = Some(EditorState {
mode: EditorMode::Delete {
issue_number: issue.number,
title: issue.title.clone(),
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
});
}
pub fn handle_editor_key(&mut self, key: KeyEvent) -> Option<IssueAction> {
let mut editor = self.editor.take()?;
if matches!(editor.mode, EditorMode::Delete { .. }) {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let EditorMode::Delete { issue_number, .. } = editor.mode {
return Some(IssueAction::Delete { issue_number });
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.status = "Delete cancelled".to_owned();
}
_ => {
self.editor = Some(editor);
}
}
return None;
}
if key.code == KeyCode::Esc {
self.status = "Edit cancelled".to_owned();
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
return self.submit_editor(editor);
}
match key.code {
KeyCode::Tab => {
editor.field = match editor.field {
EditorField::Title => EditorField::Body,
EditorField::Body => EditorField::Title,
};
}
KeyCode::Enter if editor.field == EditorField::Title => {
editor.field = EditorField::Body;
}
KeyCode::Enter => editor.body.push('\n'),
KeyCode::Backspace => match editor.field {
EditorField::Title => {
editor.title.pop();
}
EditorField::Body => {
editor.body.pop();
}
},
KeyCode::Char(character) if !character.is_control() => match editor.field {
EditorField::Title => editor.title.push(character),
EditorField::Body => editor.body.push(character),
},
_ => {}
}
self.editor = Some(editor);
None
}
pub fn restore_issue_action(&mut self, action: IssueAction) {
self.editor = Some(match action {
IssueAction::Create {
project_id,
title,
body,
} => EditorState {
mode: EditorMode::Create { project_id },
field: EditorField::Body,
title,
body,
},
IssueAction::Edit {
issue_number,
payload,
} => EditorState {
mode: EditorMode::Edit { issue_number },
field: EditorField::Body,
title: payload.title,
body: payload.body,
},
IssueAction::Delete { issue_number } => EditorState {
mode: EditorMode::Delete {
issue_number,
title: self
.focused_card()
.map(|issue| issue.title.clone())
.unwrap_or_default(),
},
field: EditorField::Title,
title: String::new(),
body: String::new(),
},
});
}
fn submit_editor(&mut self, editor: EditorState) -> Option<IssueAction> {
if editor.title.trim().is_empty() {
self.status = "Title cannot be empty".to_owned();
self.editor = Some(editor);
return None;
}
match editor.mode {
EditorMode::Create { project_id } => Some(IssueAction::Create {
project_id,
title: editor.title,
body: editor.body,
}),
EditorMode::Edit { issue_number } => Some(IssueAction::Edit {
issue_number,
payload: EditIssuePayload {
title: editor.title,
body: editor.body,
},
}),
EditorMode::Delete { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::*;
fn app() -> App {
App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![
ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
},
ProjectColumn {
id: 20,
title: "Done".to_owned(),
color: String::new(),
sorting: 1,
},
],
vec![
vec![
Issue {
id: 1,
number: 1,
title: "First".to_owned(),
body: None,
},
Issue {
id: 2,
number: 2,
title: "Second".to_owned(),
body: None,
},
],
Vec::new(),
],
)
.expect("board builds")
}
#[test]
fn navigation_stays_within_board() {
let mut app = app();
app.focus_left();
app.focus_up();
assert_eq!(app.focused_column, 0);
assert_eq!(app.focused_cards[0], 0);
app.focus_down();
app.focus_down();
app.focus_right();
app.focus_right();
assert_eq!(app.focused_cards[0], 1);
assert_eq!(app.focused_column, 1);
}
#[test]
fn cannot_move_past_board_edge() {
let app = app();
assert!(app.prepare_move(-1).is_none());
}
#[test]
fn empty_board_returns_actionable_error() {
let result = App::new_project(
Project {
id: 1,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![ProjectColumn {
id: 1,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
}],
vec![Vec::new()],
);
assert!(result.is_ok());
}
#[test]
fn native_move_uses_global_issue_and_column_ids() {
let mut app = App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![
ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
},
ProjectColumn {
id: 20,
title: "Done".to_owned(),
color: String::new(),
sorting: 1,
},
],
vec![
vec![Issue {
id: 99,
number: 7,
title: "Fix".to_owned(),
body: None,
}],
Vec::new(),
],
)
.expect("board");
let request = app.prepare_move(1).expect("move");
assert!(matches!(
request.action,
MoveAction::Project { issue_id: 99, ref payload, .. } if payload.column_id == 20
));
app.apply_move(request);
assert_eq!(app.columns[1].cards[0].id, 99);
}
#[test]
fn native_editor_creates_project_issue_action() {
let mut app = App::new_project(
Project {
id: 8,
title: "Kanban".to_owned(),
is_closed: false,
},
vec![ProjectColumn {
id: 10,
title: "Todo".to_owned(),
color: String::new(),
sorting: 0,
}],
vec![Vec::new()],
)
.expect("board");
app.begin_create();
for character in "New issue".chars() {
app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
}
app.handle_editor_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
for character in "Details".chars() {
app.handle_editor_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
}
let action =
app.handle_editor_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(matches!(
action,
Some(IssueAction::Create {
project_id: 8,
title,
body,
}) if title == "New issue" && body == "Details"
));
assert!(app.editor.is_none());
}
}
@@ -0,0 +1,145 @@
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>,
},
}
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}");
}
}
Ok(())
}
+303
View File
@@ -0,0 +1,303 @@
use std::env;
use std::fmt;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use clap::{Args as ClapArgs, Parser};
#[derive(Clone, Debug, ClapArgs)]
pub struct SharedArgs {
/// Gitea base URL; falls back to GITEA_URL
#[arg(long)]
pub url: Option<String>,
/// File containing API token; falls back to GITEA_TOKEN_FILE
#[arg(long, value_name = "PATH")]
pub token_file: Option<PathBuf>,
/// Exact native project name
#[arg(long)]
pub project: Option<String>,
/// Native project ID
#[arg(long)]
pub project_id: Option<u64>,
/// Repository owner; falls back to GITEA_OWNER
pub owner: Option<String>,
/// Repository name; falls back to GITEA_REPO
pub repo: Option<String>,
}
#[derive(Debug, Parser)]
#[command(
name = "gitea-kanban-tui",
version,
about = "Browse and move Gitea issues using native projects"
)]
pub struct Args {
#[command(flatten)]
pub shared: SharedArgs,
}
#[derive(Debug)]
pub struct Config {
pub base_url: String,
pub token: String,
pub owner: String,
pub repo: String,
pub project: Option<String>,
pub project_id: Option<u64>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ConfigError(String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
Self::from_args_with(Args::parse(), |name| env::var(name).ok())
}
pub fn from_args_with<F>(args: Args, env_var: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
Self::from_shared_args_with(args.shared, env_var)
}
pub fn from_shared_args_with<F>(args: SharedArgs, env_var: F) -> Result<Self, ConfigError>
where
F: Fn(&str) -> Option<String>,
{
let base_url = required(
args.url.or_else(|| env_var("GITEA_URL")),
"Gitea URL",
"--url or GITEA_URL",
)?;
let owner = required(
args.owner.or_else(|| env_var("GITEA_OWNER")),
"repository owner",
"OWNER argument or GITEA_OWNER",
)?;
let repo = required(
args.repo.or_else(|| env_var("GITEA_REPO")),
"repository name",
"REPO argument or GITEA_REPO",
)?;
let project = args
.project
.or_else(|| env_var("GITEA_PROJECT"))
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
let project_id = args
.project_id
.or_else(|| env_var("GITEA_PROJECT_ID").and_then(|value| value.parse::<u64>().ok()));
if project.is_some() == project_id.is_some() {
return Err(ConfigError(
"projects mode requires exactly one of --project/GITEA_PROJECT or --project-id/GITEA_PROJECT_ID"
.to_owned(),
));
}
let token = if let Some(path) = args.token_file {
read_token_file(path)?
} else if let Some(path) = env_var("GITEA_TOKEN_FILE") {
read_token_file(PathBuf::from(path))?
} else if let Some(token) = env_var("GITEA_TOKEN") {
clean_token(token, "GITEA_TOKEN")?
} else {
return Err(ConfigError(
"missing Gitea token; use --token-file, GITEA_TOKEN, or GITEA_TOKEN_FILE"
.to_owned(),
));
};
Ok(Self {
base_url: base_url.trim_end_matches('/').to_owned(),
token,
owner,
repo,
project,
project_id,
})
}
}
fn required(value: Option<String>, name: &str, source: &str) -> Result<String, ConfigError> {
match value.map(|value| value.trim().to_owned()) {
Some(value) if !value.is_empty() => Ok(value),
_ => Err(ConfigError(format!("missing {name}; use {source}"))),
}
}
fn clean_token(token: String, source: &str) -> Result<String, ConfigError> {
let token = token.trim().to_owned();
if token.is_empty() {
Err(ConfigError(format!("{source} contains an empty token")))
} else {
Ok(token)
}
}
fn read_token_file(path: PathBuf) -> Result<String, ConfigError> {
let metadata = fs::symlink_metadata(&path).map_err(|error| {
ConfigError(format!(
"cannot inspect token file {}: {error}",
path.display()
))
})?;
if !metadata.is_file() {
return Err(ConfigError(format!(
"token path {} is not a regular file",
path.display()
)));
}
#[cfg(unix)]
if metadata.permissions().mode() & 0o077 != 0 {
return Err(ConfigError(format!(
"token file {} must not be group- or world-readable",
path.display()
)));
}
let token = fs::read_to_string(&path).map_err(|error| {
ConfigError(format!(
"cannot read token file {}: {error}",
path.display()
))
})?;
clean_token(token, &format!("token file {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_env(_: &str) -> Option<String> {
None
}
fn token_env(name: &str) -> Option<String> {
(name == "GITEA_TOKEN").then(|| "secret".to_owned())
}
#[test]
fn parses_explicit_configuration() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example/",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let config = Config::from_args_with(args, token_env).expect("config is valid");
assert_eq!(config.base_url, "https://gitea.example");
assert_eq!(config.owner, "owner");
assert_eq!(config.repo, "repo");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.token, "secret");
}
#[test]
fn parses_native_project_by_name() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let config = Config::from_args_with(args, token_env).expect("config is valid");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.project_id, None);
}
#[test]
fn requires_project_selector() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"owner",
"repo",
])
.expect("arguments parse");
let error =
Config::from_args_with(args, token_env).expect_err("project selector is required");
assert!(error.to_string().contains("--project"));
}
#[test]
fn native_project_selector_is_unambiguous() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"owner",
"repo",
])
.expect("arguments parse");
let error = Config::from_args_with(args, token_env).expect_err("selector is required");
assert!(error.to_string().contains("exactly one"));
}
#[test]
fn falls_back_to_environment() {
let args = Args::try_parse_from(["gitea-kanban-tui"]).expect("arguments parse");
let config = Config::from_args_with(args, |name| {
match name {
"GITEA_URL" => Some("https://gitea.example"),
"GITEA_TOKEN" => Some("secret"),
"GITEA_PROJECT" => Some("Kanban"),
"GITEA_OWNER" => Some("owner"),
"GITEA_REPO" => Some("repo"),
_ => None,
}
.map(str::to_owned)
})
.expect("config is valid");
assert_eq!(config.project.as_deref(), Some("Kanban"));
assert_eq!(config.owner, "owner");
}
#[test]
fn reports_missing_token_without_exposing_values() {
let args = Args::try_parse_from([
"gitea-kanban-tui",
"--url",
"https://gitea.example",
"--project",
"Kanban",
"owner",
"repo",
])
.expect("arguments parse");
let error = match Config::from_args_with(args, empty_env) {
Ok(_) => panic!("token should be required"),
Err(error) => error,
};
assert!(error.to_string().contains("GITEA_TOKEN_FILE"));
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod api;
pub mod app;
pub mod config;
pub mod model;
pub mod operations;
pub mod terminal;
pub mod text;
pub mod ui;
+125
View File
@@ -0,0 +1,125 @@
use std::error::Error;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use gitea_kanban_tui::api::{load_project_board, GiteaApi, GiteaClient};
use gitea_kanban_tui::app::{App, IssueAction, MoveAction};
use gitea_kanban_tui::config::Config;
use gitea_kanban_tui::operations;
use gitea_kanban_tui::terminal::TerminalGuard;
use gitea_kanban_tui::text::sanitize_terminal_text;
use gitea_kanban_tui::ui;
fn main() {
if let Err(error) = run() {
eprintln!("error: {}", sanitize_terminal_text(&error.to_string()));
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn Error>> {
let config = Config::load()?;
let client = GiteaClient::new(&config)?;
let mut app = load_board(&client, &config)?;
let repository = format!("{}/{}", config.owner, config.repo);
let mut terminal = TerminalGuard::enter()?;
loop {
terminal
.terminal()
.draw(|frame| ui::draw(frame, &app, &repository))?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if app.editor.is_some() {
if let Some(action) = app.handle_editor_key(key) {
issue_action(&client, &config, &mut app, action);
}
continue;
}
match key.code {
KeyCode::Char('q') => break,
KeyCode::Left | KeyCode::Char('h') => app.focus_left(),
KeyCode::Right | KeyCode::Char('l') => app.focus_right(),
KeyCode::Up | KeyCode::Char('k') => app.focus_up(),
KeyCode::Down | KeyCode::Char('j') => app.focus_down(),
KeyCode::Char('H') => move_card(&client, &mut app, -1),
KeyCode::Char('L') => move_card(&client, &mut app, 1),
KeyCode::Char('n') => app.begin_create(),
KeyCode::Char('e') => app.begin_edit(),
KeyCode::Char('d') => app.begin_delete(),
KeyCode::Char('r') => match load_board(&client, &config) {
Ok(board) => app = board,
Err(error) => app.status = format!("Refresh failed: {error}"),
},
KeyCode::Char('?') => app.show_help = !app.show_help,
_ => {}
}
}
Ok(())
}
fn issue_action(client: &impl GiteaApi, config: &Config, app: &mut App, action: IssueAction) {
let retry_action = action.clone();
let message = match action {
IssueAction::Create {
project_id,
title,
body,
} => operations::create_project_issue(client, project_id, title, body)
.map(|issue| format!("Created issue #{}", issue.number)),
IssueAction::Edit {
issue_number,
payload,
} => operations::edit_issue(client, issue_number, &payload)
.map(|_| format!("Updated issue #{issue_number}")),
IssueAction::Delete { issue_number } => operations::delete_issue(client, issue_number)
.map(|_| format!("Deleted issue #{issue_number}")),
};
match message {
Ok(message) => match load_board(client, config) {
Ok(mut board) => {
board.status = message;
*app = board;
}
Err(error) => app.status = format!("Saved, refresh failed: {error}"),
},
Err(error) => {
app.restore_issue_action(retry_action);
app.status = format!("Issue operation failed: {error}");
}
}
}
fn load_board(client: &impl GiteaApi, config: &Config) -> Result<App, Box<dyn Error>> {
let board = load_project_board(client, config)?;
App::new_project(board.project, board.columns, board.issues_by_column)
.map_err(|error| error.into())
}
fn move_card(client: &impl GiteaApi, app: &mut App, offset: isize) {
let Some(request) = app.prepare_move(offset) else {
app.status = "Cannot move card beyond board edge".to_owned();
return;
};
let result = match &request.action {
MoveAction::Project {
issue_id,
project_id,
payload,
} => operations::move_project_issue(client, *project_id, *issue_id, payload),
};
match result {
Ok(()) => app.apply_move(request),
Err(error) => app.status = format!("Move failed: {error}"),
}
}
+120
View File
@@ -0,0 +1,120 @@
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(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"})
);
}
}
@@ -0,0 +1,36 @@
use crate::api::{ApiError, GiteaApi};
use crate::model::{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)
}
+45
View File
@@ -0,0 +1,45 @@
use std::io::{self, Stdout};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
pub struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
pub fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(error) = execute!(stdout, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(error);
}
let backend = CrosstermBackend::new(stdout);
match Terminal::new(backend) {
Ok(terminal) => Ok(Self { terminal }),
Err(error) => {
let mut stdout = io::stdout();
let _ = execute!(stdout, LeaveAlternateScreen);
let _ = disable_raw_mode();
Err(error)
}
}
}
pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
&mut self.terminal
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
+46
View File
@@ -0,0 +1,46 @@
pub fn sanitize_terminal_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect()
}
pub fn sanitize_editor_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character == '\n' || !character.is_control() {
character
} else {
' '
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_terminal_control_characters() {
assert_eq!(
sanitize_terminal_text("safe\u{1b}[31m\ntext"),
"safe [31m text"
);
}
#[test]
fn editor_sanitizer_preserves_newlines() {
assert_eq!(
sanitize_editor_text("first\nsecond\u{1b}[31m"),
"first\nsecond [31m"
);
}
}
+208
View File
@@ -0,0 +1,208 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::{App, Column, EditorField, EditorMode, EditorState};
use crate::text::{sanitize_editor_text, sanitize_terminal_text};
pub fn draw(frame: &mut Frame<'_>, app: &App, repository: &str) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(6),
Constraint::Length(4),
Constraint::Length(if app.show_help { 3 } else { 1 }),
])
.split(frame.area());
frame.render_widget(
Paragraph::new(format!(
"Gitea Kanban — {} — {}",
sanitize_terminal_text(repository),
sanitize_terminal_text(&app.board_title)
))
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
areas[0],
);
draw_columns(frame, app, areas[1]);
draw_detail(frame, app, areas[2]);
let help = if app.show_help {
format!(
"{}\n←/h →/l: column ↑/k ↓/j: card H/L: move n: new e: edit d: delete r: refresh ?: help q: quit",
sanitize_terminal_text(&app.status)
)
} else {
format!(
"{} | ?: help q: quit",
sanitize_terminal_text(&app.status)
)
};
frame.render_widget(Paragraph::new(help).wrap(Wrap { trim: true }), areas[3]);
if let Some(editor) = &app.editor {
draw_editor(frame, editor);
}
}
fn draw_editor(frame: &mut Frame<'_>, editor: &EditorState) {
let area = centered_rect(frame.area(), 80, 45);
frame.render_widget(Clear, area);
let (title, content) = match &editor.mode {
EditorMode::Delete {
issue_number,
title,
} => (
"Delete issue".to_owned(),
format!(
"Delete issue #{issue_number} — {}?\n\n[y] confirm [n/Esc] cancel",
sanitize_terminal_text(title)
),
),
EditorMode::Create { .. } => (
"New issue".to_owned(),
editor_content(editor, "Create issue"),
),
EditorMode::Edit { issue_number } => (
format!("Edit issue #{issue_number}"),
editor_content(editor, "Edit issue"),
),
};
frame.render_widget(
Paragraph::new(content)
.block(
Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL),
)
.wrap(Wrap { trim: false }),
area,
);
}
fn editor_content(editor: &EditorState, action: &str) -> String {
let title_marker = if editor.field == EditorField::Title {
"▶ "
} else {
" "
};
let body_marker = if editor.field == EditorField::Body {
"▶ "
} else {
" "
};
format!(
"{title_marker}Title: {}\n{body_marker}Body: {}\n\nTab: switch field Enter: title → body Ctrl-S: save Esc: cancel\n{action}",
sanitize_terminal_text(&editor.title),
sanitize_editor_text(&editor.body)
)
}
fn centered_rect(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}
fn draw_columns(frame: &mut Frame<'_>, app: &App, area: Rect) {
let widths = vec![Constraint::Ratio(1, app.columns.len() as u32); app.columns.len()];
let areas = Layout::default()
.direction(Direction::Horizontal)
.constraints(widths)
.split(area);
for (index, column) in app.columns.iter().enumerate() {
draw_column(frame, app, column, index, areas[index]);
}
}
fn draw_column(frame: &mut Frame<'_>, app: &App, column: &Column, index: usize, area: Rect) {
let focused = index == app.focused_column;
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let items: Vec<ListItem<'_>> = column
.cards
.iter()
.map(|issue| {
ListItem::new(Line::from(vec![
Span::styled(
format!("#{} ", issue.number),
Style::default().fg(Color::DarkGray),
),
Span::raw(sanitize_terminal_text(&issue.title)),
]))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!(
" {} ({}) ",
sanitize_terminal_text(&column.spec.title),
column.cards.len()
))
.borders(Borders::ALL)
.border_style(border_style),
)
.highlight_style(
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
if focused && !column.cards.is_empty() {
state.select(Some(app.focused_cards[index]));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
let text = app
.focused_card()
.map(|issue| {
let body = sanitize_terminal_text(
&issue
.body
.as_deref()
.unwrap_or("No description")
.replace('\n', " "),
);
format!(
"#{} {}\n{}",
issue.number,
sanitize_terminal_text(&issue.title),
body
)
})
.unwrap_or_else(|| "No card in focused column".to_owned());
frame.render_widget(
Paragraph::new(text)
.block(Block::default().title(" Detail ").borders(Borders::ALL))
.wrap(Wrap { trim: true }),
area,
);
}
+15 -12
View File
@@ -18,20 +18,26 @@
let let
pname = "gitea"; pname = "gitea";
version = "1.26.2"; version = "1.27.3";
src = ./source; src = ./source;
pnpm = pnpm_10; pnpm = pnpm_10;
pnpmPatches = [ ./pnpm-engine.patch ];
frontend = stdenv.mkDerivation { frontend = stdenv.mkDerivation {
pname = "gitea-frontend"; pname = "gitea-frontend";
inherit src version; inherit src version;
patches = pnpmPatches;
pnpmDeps = fetchPnpmDeps { pnpmDeps = fetchPnpmDeps {
pname = "gitea-frontend"; pname = "gitea-frontend";
inherit version src; inherit version src;
inherit pnpm; inherit pnpm;
patches = pnpmPatches;
fetcherVersion = 3; fetcherVersion = 3;
hash = "sha256-Qo0DLuZv+2GVLsBfCv/6CC9E/qhSE4HwV4StQL4HX4Y="; prePnpmInstall = ''
pnpm config set engine-strict false
'';
hash = "sha256-H1sNMKRkoPlkheJFJVaof4bJ4gQHnbhosJaUqj9X8Gg=";
}; };
nativeBuildInputs = [ nativeBuildInputs = [
@@ -40,6 +46,10 @@ let
pnpm pnpm
]; ];
prePnpmInstall = ''
pnpm config set engine-strict false
'';
buildPhase = '' buildPhase = ''
make frontend make frontend
''; '';
@@ -54,7 +64,8 @@ buildGo126Module rec {
inherit pname version src; inherit pname version src;
proxyVendor = true; proxyVendor = true;
vendorHash = "sha256-7+M1n8RSgB3gZ/2na4RF9kYOf90H0bnsJZMDKpgAy64="; deleteVendor = true;
vendorHash = "sha256-YRBMGWKIZgMxOXaXG2bIBj1XzkhSwiMyfRy+yQGw+Bo=";
outputs = [ outputs = [
"out" "out"
@@ -63,17 +74,8 @@ buildGo126Module rec {
patches = [ ./static-root-path.patch ]; patches = [ ./static-root-path.patch ];
overrideModAttrs = _: {
postPatch = ''
substituteInPlace go.mod \
--replace-fail "go 1.26.3" "go 1.26"
'';
};
postPatch = '' postPatch = ''
substituteInPlace modules/setting/server.go --subst-var data substituteInPlace modules/setting/server.go --subst-var data
substituteInPlace go.mod \
--replace-fail "go 1.26.3" "go 1.26"
''; '';
subPackages = [ "." ]; subPackages = [ "." ];
@@ -93,6 +95,7 @@ buildGo126Module rec {
]; ];
postInstall = '' postInstall = ''
mv "$out/bin/gitea.dev" "$out/bin/gitea"
mkdir $data mkdir $data
ln -s ${frontend}/public $data/public ln -s ${frontend}/public $data/public
cp -R ./{templates,options} $data cp -R ./{templates,options} $data
+13
View File
@@ -0,0 +1,13 @@
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"type": "module",
- "packageManager": "pnpm@11.9.0",
+ "packageManager": "pnpm@10.12.4",
"engines": {
"node": ">= 22.18.0",
- "pnpm": ">= 11.0.0"
+ "pnpm": ">= 10.0.0"
},
+1 -4
View File
@@ -37,10 +37,7 @@ groups:
name: BUGFIXES name: BUGFIXES
labels: labels:
- type/bug - type/bug
-
name: API
labels:
- modifies/api
- -
name: TESTING name: TESTING
labels: labels:
@@ -1,6 +1,6 @@
{ {
"name": "Gitea DevContainer", "name": "Gitea DevContainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie", "image": "mcr.microsoft.com/devcontainers/go:1.26-trixie",
"containerEnv": { "containerEnv": {
// override "local" from packaged version // override "local" from packaged version
"GOTOOLCHAIN": "auto" "GOTOOLCHAIN": "auto"
-8
View File
@@ -40,9 +40,7 @@ cpu.out
*.log *.log
/gitea /gitea
/gitea-vet
/debug /debug
/integrations.test
/bin /bin
/dist /dist
@@ -54,12 +52,6 @@ cpu.out
/indexers /indexers
/log /log
/tests/integration/gitea-integration-* /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 /tests/*.ini
/node_modules /node_modules
/yarn.lock /yarn.lock
+1 -1
View File
@@ -18,7 +18,7 @@ indent_style = tab
[templates/custom/*.tmpl] [templates/custom/*.tmpl]
insert_final_newline = false insert_final_newline = false
[templates/swagger/v1_json.tmpl] [templates/swagger/*_json.tmpl]
indent_style = space indent_style = space
insert_final_newline = false insert_final_newline = false
+1
View File
@@ -4,6 +4,7 @@
/assets/*.json linguist-generated /assets/*.json linguist-generated
/public/assets/img/svg/*.svg linguist-generated /public/assets/img/svg/*.svg linguist-generated
/templates/swagger/v1_json.tmpl linguist-generated /templates/swagger/v1_json.tmpl linguist-generated
/templates/swagger/v1_openapi3_json.tmpl linguist-generated
/options/fileicon/** linguist-generated /options/fileicon/** linguist-generated
/vendor/** -text -eol linguist-vendored /vendor/** -text -eol linguist-vendored
/web_src/js/vendor/** -text -eol linguist-vendored /web_src/js/vendor/** -text -eol linguist-vendored
@@ -1,42 +0,0 @@
<!-- NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue -->
<!--
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Please take a moment to check that your issue doesn't already exist.
4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq)
5. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report.
-->
- Gitea version (or commit ref):
- Git version:
- Operating system:
<!-- Please include information on whether you built gitea yourself, used one of our downloads or are using some other package -->
<!-- Please also tell us how you are running gitea, e.g. if it is being run from docker, a command-line, systemd etc. --->
<!-- If you are using a package or systemd tell us what distribution you are using -->
- Database (use `[x]`):
- [ ] PostgreSQL
- [ ] MySQL
- [ ] MSSQL
- [ ] SQLite
- Can you reproduce the bug at https://demo.gitea.com:
- [ ] Yes (provide example URL)
- [ ] No
- Log gist:
<!-- It really is important to provide pertinent logs -->
<!-- Please read https://docs.gitea.com/administration/logging-config#collecting-logs-for-help -->
<!-- In addition, if your problem relates to git commands set `RUN_MODE=dev` at the top of app.ini -->
## Description
<!-- If using a proxy or a CDN (e.g. CloudFlare) in front of gitea, please
disable the proxy/CDN fully and connect to gitea directly to confirm
the issue still persists without those services. -->
...
## Screenshots
<!-- **If this issue involves the Web Interface, please include a screenshot** -->
+10 -73
View File
@@ -1,91 +1,28 @@
name: Bug Report name: Bug Report
description: Found something you weren't expecting? Report it here! description: Something isn't working as expected.
labels: ["type/bug"] labels: ["type/bug"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue. - **Security issue?** Email security@gitea.io instead of opening a public issue.
- type: markdown - **Need help** with setup or configuration? Ask on [Discord](https://discord.gg/Gitea) or the [forum](https://forum.gitea.com).
attributes: - Search [existing issues](https://github.com/go-gitea/gitea/issues?q=is%3Aissue) first.
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.
- type: input - type: input
id: gitea-ver id: gitea-ver
attributes: attributes:
label: Gitea Version label: Gitea Version
description: Gitea version (or commit reference) of your instance
validations: validations:
required: true required: true
- type: dropdown - type: textarea
id: can-reproduce id: description
attributes: attributes:
label: Can you reproduce the bug on the Gitea demo site? label: What happened?
description: | description: What you did, what you expected to happen, and what happened instead. Include logs if relevant.
If so, please provide a URL in the Description field
URL of Gitea demo: https://demo.gitea.com
options:
- "Yes"
- "No"
validations: validations:
required: true 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 - type: textarea
id: screenshots id: environment
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
attributes: attributes:
label: How are you running Gitea? label: How are you running Gitea?
description: | description: Install method (binary, Docker, package), operating system, and database.
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
@@ -1,24 +1,20 @@
name: Feature Request 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"] labels: ["type/proposal"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
1. Please speak English, this is the language all maintainers can speak and write. Search [existing issues](https://github.com/go-gitea/gitea/issues?q=is%3Aissue) first.
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.
- type: textarea - type: textarea
id: description id: problem
attributes: attributes:
label: Feature Description label: What problem would this solve?
placeholder: |
I think it would be great if Gitea had...
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: screenshots id: proposal
attributes: attributes:
label: Screenshots label: What do you propose?
description: If you can, provide screenshots of an implementation on another site e.g. GitHub validations:
required: true
@@ -1,66 +0,0 @@
name: Web Interface Bug Report
description: Something doesn't look quite as it should? Report it here!
labels: ["type/bug", "topic/ui"]
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@gitea.io instead of opening a public issue.
- type: markdown
attributes:
value: |
1. Please speak English, this is the language all maintainers can speak and write.
2. Please ask questions or configuration/deploy problems on our Discord
server (https://discord.gg/gitea) or forum (https://forum.gitea.com).
3. Please take a moment to check that your issue doesn't already exist.
4. Make sure it's not mentioned in the FAQ (https://docs.gitea.com/help/faq)
5. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report.
6. In particular it's really important to provide pertinent logs. If you are certain that this is a javascript
error, show us the javascript console. If the error appears to relate to Gitea the server you must also give us
DEBUG level logs. (See https://docs.gitea.com/administration/logging-config#collecting-logs-for-help)
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below)
If using a proxy or a CDN (e.g. CloudFlare) in front of gitea, please disable the proxy/CDN fully and connect to gitea directly to confirm the issue still persists without those services.
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Please provide at least 1 screenshot showing the issue.
validations:
required: true
- type: input
id: gitea-ver
attributes:
label: Gitea Version
description: Gitea version (or commit reference) your instance is running
validations:
required: true
- type: dropdown
id: can-reproduce
attributes:
label: Can you reproduce the bug on the Gitea demo site?
description: |
If so, please provide a URL in the Description field
URL of Gitea demo: https://demo.gitea.com
options:
- "Yes"
- "No"
validations:
required: true
- type: input
id: os-ver
attributes:
label: Operating System
description: The operating system you are using to access Gitea
- type: input
id: browser-ver
attributes:
label: Browser Version
description: The browser and version that you are using to access Gitea
validations:
required: true
@@ -0,0 +1,29 @@
name: docker-dryrun
description: Composite action that performs the container build steps for a single platform.
inputs:
platform:
description: "The target platform: linux/amd64, linux/arm64, linux/riscv64."
required: true
runs:
using: composite
steps:
- uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build regular image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: ${{ inputs.platform }}
push: false
file: Dockerfile
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful
- name: Build rootless image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: ${{ inputs.platform }}
push: false
file: Dockerfile.rootless
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless
@@ -0,0 +1,17 @@
name: free-disk-space
description: Free space on / before large cache restores
# Delete preinstalled toolchains which gitea doesn't use and show disk space usage
runs:
using: composite
steps:
- shell: bash
run: |
echo "free space before cleanup:"
df -h /
for dir in /usr/local/lib/android /usr/local/.ghcup /opt/ghc /usr/share/dotnet; do
sudo rm -rf "$dir" &
done
wait
echo "free space after cleanup:"
df -h /
@@ -0,0 +1,50 @@
name: go-caches
description: Restore the go module, build, and golangci-lint caches. Save only on the cache-seeder workflow.
# Only the cache-seeder workflow saves; rename requires updating cache-seeder.yml.
# The lint job restores but does not save the gobuild cache, so only one writer
# (the gobuild job) populates it and there is no contention on the cache key.
# Seeder restores by exact key only (no restore-keys) so each go.sum seeds a clean
# cache and size stays bounded; do not add restore-keys here. PR runs keep them.
inputs:
lint-cache:
description: Restore (and save in cache-seeder) ~/.cache/golangci-lint
default: "false"
runs:
using: composite
steps:
- if: ${{ github.workflow == 'cache-seeder' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
- if: ${{ github.workflow != 'cache-seeder' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/go/pkg/mod
key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
restore-keys: gomod-${{ runner.os }}-${{ runner.arch }}
- if: ${{ github.workflow == 'cache-seeder' && inputs.lint-cache != 'true' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/go-build
key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
- if: ${{ github.workflow != 'cache-seeder' || inputs.lint-cache == 'true' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/go-build
key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }}
restore-keys: gobuild-${{ runner.os }}-${{ runner.arch }}
- if: ${{ inputs.lint-cache == 'true' && github.workflow == 'cache-seeder' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/golangci-lint
key: golint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum', '.golangci.yml') }}
- if: ${{ inputs.lint-cache == 'true' && github.workflow != 'cache-seeder' }}
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/golangci-lint
key: golint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum', '.golangci.yml') }}
restore-keys: golint-${{ runner.os }}-${{ runner.arch }}
@@ -0,0 +1,24 @@
name: go-setup
description: Set up go and restore caches
inputs:
cache:
description: Restore go caches
default: "true"
lint-cache:
description: Also restore the golangci-lint cache
default: "false"
runs:
using: composite
steps:
- uses: ./.github/actions/free-disk-space
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
check-latest: true
cache: false
- if: ${{ inputs.cache == 'true' }}
uses: ./.github/actions/go-cache
with:
lint-cache: ${{ inputs.lint-cache }}
@@ -0,0 +1,22 @@
name: node-setup
description: Set up pnpm and node and restore caches
inputs:
cache:
description: Cache pnpm downloads
default: "true"
runs:
using: composite
steps:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- if: ${{ inputs.cache == 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- if: ${{ inputs.cache != 'true' }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
@@ -0,0 +1,40 @@
name: pgsql-shard
description: Run one pgsql integration test shard
inputs:
shard:
description: Shard index
required: true
total-shards:
description: Total shard count
required: true
run-migration:
description: Also run migration tests
default: "false"
runs:
using: composite
steps:
- name: Add hosts to /etc/hosts
shell: bash
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts'
- shell: bash
run: make deps-backend
- shell: bash
run: make backend
env:
TAGS: bindata
- name: run migration tests
if: ${{ inputs.run-migration == 'true' }}
shell: bash
run: GITEA_TEST_DATABASE=pgsql make test-migration
- name: run tests
shell: bash
run: GITEA_TEST_DATABASE=pgsql make test-integration
env:
# pgsql is chosen to be the unlucky one to run with the slow "race detector", it is about 60% slower.
GOTEST_FLAGS: -race -timeout=40m
TAGS: bindata gogit
TEST_LDAP: 1
TEST_SHARD: ${{ inputs.shard }}
TEST_TOTAL_SHARDS: ${{ inputs.total-shards }}
-10
View File
@@ -1,10 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
labels: [modifies/dependencies]
directory: /
schedule:
interval: daily
cooldown:
default-days: 5
-77
View File
@@ -1,80 +1,3 @@
modifies/docs:
- changed-files:
- any-glob-to-any-file:
- "**/*.md"
- "docs/**"
modifies/templates:
- changed-files:
- all-globs-to-any-file:
- "templates/**"
- "!templates/swagger/v1_json.tmpl"
modifies/api:
- changed-files:
- any-glob-to-any-file:
- "routers/api/**"
- "templates/swagger/v1_json.tmpl"
modifies/cli:
- changed-files:
- any-glob-to-any-file:
- "cmd/**"
modifies/translation:
- changed-files:
- any-glob-to-any-file:
- "options/locale/*.ini"
modifies/migrations:
- changed-files:
- any-glob-to-any-file:
- "models/migrations/**"
modifies/internal:
- changed-files:
- any-glob-to-any-file:
- ".air.toml"
- "Makefile"
- "Dockerfile"
- "Dockerfile.rootless"
- ".dockerignore"
- "docker/**"
- ".editorconfig"
- ".eslintrc.cjs"
- ".golangci.yml"
- ".markdownlint.yaml"
- ".spectral.yaml"
- "stylelint.config.*"
- ".yamllint.yaml"
- ".github/**"
- ".gitea/**"
- ".devcontainer/**"
- "build/**"
- "contrib/**"
modifies/dependencies:
- changed-files:
- any-glob-to-any-file:
- "package.json"
- "pnpm-lock.yaml"
- "pyproject.toml"
- "uv.lock"
- "go.mod"
- "go.sum"
modifies/go:
- changed-files:
- any-glob-to-any-file:
- "**/*.go"
modifies/frontend:
- changed-files:
- any-glob-to-any-file:
- "*.js"
- "*.ts"
- "web_src/**"
docs-update-needed: docs-update-needed:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
+9 -10
View File
@@ -1,10 +1,9 @@
<!-- start tips --> <!--
Please check the following: Before submitting:
1. Make sure you are targeting the `main` branch, pull requests on release branches are only allowed for backports. - Target the `main` branch; release branches are for backports only.
2. Make sure you have read contributing guidelines: https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md . - Use a Conventional Commits title, e.g. `fix(repo): handle empty branch names`.
3. For documentations contribution, please go to https://gitea.com/gitea/docs - Read the contributing guidelines: https://github.com/go-gitea/gitea/blob/main/CONTRIBUTING.md
4. Describe what your pull request does and which issue you're targeting (if any). - Documentation changes go to https://gitea.com/gitea/docs
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 `---`. Describe your change below and link any issue it fixes.
7. Delete all these tips before posting. -->
<!-- end tips -->
+72
View File
@@ -0,0 +1,72 @@
# Populates main's cache scope so PR runs warm-start from it. Saves the go
# module, go build (incl. test compile), and golangci-lint caches.
#
# Caches are ref-scoped: PR runs read their own scope then fall back to the
# base branch. Per .github/actions/go-cache/action.yml, PRs are restore-only,
# so push-to-main is the only opportunity to populate the fallback scope.
name: cache-seeder
on:
push:
branches:
- main
paths:
- "go.sum"
- ".golangci.yml"
- ".github/actions/go-cache/action.yml"
- ".github/actions/go-setup/action.yml"
- ".github/workflows/cache-seeder.yml"
concurrency:
group: cache-seeder
cancel-in-progress: true
permissions:
contents: read
jobs:
gobuild:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
- run: make deps-backend deps-tools
- run: TAGS="bindata" make backend
- run: TAGS="bindata gogit" GOEXPERIMENT="" make backend
- name: warm test compile cache (bindata)
env:
TAGS: bindata
GOTEST_FLAGS: -race -list=^$$ -count=1
run: make test-backend
- name: warm test compile cache (bindata gogit)
env:
TAGS: bindata gogit
GOEXPERIMENT:
GOTEST_FLAGS: -race -list=^$$ -count=1
run: make test-backend
- name: warm integration compile cache
run: |
TAGS="bindata" make test-integration-compile
TAGS="bindata gogit" GOEXPERIMENT="" make test-integration-compile
TAGS="bindata gogit" GOTEST_FLAGS="-race" make test-integration-compile
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { tags: "bindata", target: "lint-backend" }
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/go-setup
with:
lint-cache: "true"
- run: make deps-backend deps-tools
- run: make generate-go
env:
TAGS: ${{ matrix.tags }}
- run: make ${{ matrix.target }}
env:
TAGS: ${{ matrix.tags }}
@@ -1,22 +0,0 @@
name: cron-flake-updater
on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 0' # runs weekly on Sunday at 00:00
jobs:
nix-flake-update:
permissions:
contents: write
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@v3
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "Update Nix flake"
pr-labels: |
dependencies
+3 -3
View File
@@ -12,15 +12,15 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- run: make generate-gitignore - run: make generate-gitignore
timeout-minutes: 40 timeout-minutes: 40
- name: push translations to repo - name: push translations to repo
uses: appleboy/git-push-action@v1.2.0 uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0
with: with:
author_email: "teabot@gitea.io" author_email: "teabot@gitea.io"
author_name: GiteaBot 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: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: crowdin/github-action@v2 - uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with: with:
upload_sources: true upload_sources: true
upload_translations: false upload_translations: false
@@ -29,7 +29,7 @@ jobs:
- name: update locales - name: update locales
run: ./build/update-locales.sh run: ./build/update-locales.sh
- name: push translations to repo - name: push translations to repo
uses: appleboy/git-push-action@v1.2.0 uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0
with: with:
author_email: "teabot@gitea.io" author_email: "teabot@gitea.io"
author_name: GiteaBot author_name: GiteaBot
+43 -10
View File
@@ -15,19 +15,26 @@ on:
value: ${{ jobs.detect.outputs.templates }} value: ${{ jobs.detect.outputs.templates }}
docker: docker:
value: ${{ jobs.detect.outputs.docker }} value: ${{ jobs.detect.outputs.docker }}
dockerfile:
value: ${{ jobs.detect.outputs.dockerfile }}
swagger: swagger:
value: ${{ jobs.detect.outputs.swagger }} value: ${{ jobs.detect.outputs.swagger }}
yaml: yaml:
value: ${{ jobs.detect.outputs.yaml }} value: ${{ jobs.detect.outputs.yaml }}
json: json:
value: ${{ jobs.detect.outputs.json }} value: ${{ jobs.detect.outputs.json }}
e2e:
value: ${{ jobs.detect.outputs.e2e }}
shell:
value: ${{ jobs.detect.outputs.shell }}
permissions:
contents: read
jobs: jobs:
detect: detect:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3 timeout-minutes: 3
permissions:
contents: read
outputs: outputs:
backend: ${{ steps.changes.outputs.backend }} backend: ${{ steps.changes.outputs.backend }}
frontend: ${{ steps.changes.outputs.frontend }} frontend: ${{ steps.changes.outputs.frontend }}
@@ -35,12 +42,15 @@ jobs:
actions: ${{ steps.changes.outputs.actions }} actions: ${{ steps.changes.outputs.actions }}
templates: ${{ steps.changes.outputs.templates }} templates: ${{ steps.changes.outputs.templates }}
docker: ${{ steps.changes.outputs.docker }} docker: ${{ steps.changes.outputs.docker }}
dockerfile: ${{ steps.changes.outputs.dockerfile }}
swagger: ${{ steps.changes.outputs.swagger }} swagger: ${{ steps.changes.outputs.swagger }}
yaml: ${{ steps.changes.outputs.yaml }} yaml: ${{ steps.changes.outputs.yaml }}
json: ${{ steps.changes.outputs.json }} json: ${{ steps.changes.outputs.json }}
e2e: ${{ steps.changes.outputs.e2e }}
shell: ${{ steps.changes.outputs.shell }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dorny/paths-filter@v4 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes id: changes
with: with:
filters: | filters: |
@@ -54,49 +64,61 @@ jobs:
- ".golangci.yml" - ".golangci.yml"
- ".editorconfig" - ".editorconfig"
- "options/locale/locale_en-US.json" - "options/locale/locale_en-US.json"
- "models/fixtures/**"
- "tests/*.ini.tmpl"
- "tests/gitea-repositories-meta/**"
- "tests/testdata/**"
- "tools/test-integration.sh"
frontend: frontend:
- "*.js"
- "*.ts" - "*.ts"
- "web_src/**" - "web_src/**"
- "tools/*.js" - "tools/generate-svg.ts"
- "tools/*.ts" - "tools/generate-svg-vscode-extensions.json"
- "tsconfig.json"
- "assets/emoji.json" - "assets/emoji.json"
- "package.json" - "package.json"
- "pnpm-lock.yaml" - "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "Makefile" - "Makefile"
- ".eslintrc.cjs"
- ".npmrc"
docs: docs:
- "**/*.md" - "**/*.md"
- ".markdownlint.yaml" - ".markdownlint.yaml"
- "package.json" - "package.json"
- "pnpm-lock.yaml" - "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
actions: actions:
- ".github/workflows/*" - ".github/workflows/*"
- ".github/actions/**"
- "Makefile" - "Makefile"
templates: templates:
- "tools/lint-templates-*.js" - "tools/lint-templates-*.ts"
- "templates/**/*.tmpl" - "templates/**/*.tmpl"
- "pyproject.toml" - "pyproject.toml"
- "uv.lock" - "uv.lock"
docker: docker:
- ".github/workflows/pull-docker-dryrun.yml" - ".github/workflows/pull-docker-dryrun.yml"
- ".github/actions/docker-dryrun/**"
- "Dockerfile" - "Dockerfile"
- "Dockerfile.rootless" - "Dockerfile.rootless"
- "docker/**" - "docker/**"
- "Makefile" - "Makefile"
dockerfile:
- "Dockerfile"
- "Dockerfile.rootless"
swagger: swagger:
- "templates/swagger/v1_json.tmpl" - "templates/swagger/v1_json.tmpl"
- "templates/swagger/v1_input.json" - "templates/swagger/v1_input.json"
- "Makefile" - "Makefile"
- "package.json" - "package.json"
- "pnpm-lock.yaml" - "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".spectral.yaml" - ".spectral.yaml"
yaml: yaml:
@@ -107,3 +129,14 @@ jobs:
json: json:
- "**/*.json" - "**/*.json"
- "**/*.json5"
- "eslint.json.config.ts"
e2e:
- "tests/e2e/**"
- "tools/test-e2e.sh"
- "playwright.config.ts"
shell:
- "**/*.sh"
- ".shellcheckrc"
@@ -0,0 +1,26 @@
name: giteabot backport
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
giteabot:
if: github.repository == 'go-gitea/gitea'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: go-gitea/giteabot@912675d47455ac93be82d8bda4667a02b20a6fe4 # v1.0.4
with:
github_token: ${{ secrets.GITEABOT_TOKEN }}
gitea_fork: giteabot/gitea
checks: backport
+64
View File
@@ -0,0 +1,64 @@
name: giteabot
on:
# When main advances, rerun merge queue maintenance so the oldest
# reviewed/wait-merge PR can be updated against the new base promptly.
push:
branches:
- main
# pull_request_target gives this workflow access to GITEABOT_TOKEN on PRs from
# forks, which the bot needs to write labels, statuses and comments. Safe here
# because the job only runs a pinned action and never checks out PR HEAD.
# These PR lifecycle events drive label maintenance, queue maintenance, and
# explicit bot actions triggered by relevant label changes.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types:
- opened
- synchronize
- labeled
- unlabeled
- closed
- review_requested
- review_request_removed
# Review events keep review-derived state such as lgtm labels and status checks
# in sync after approvals, edits, or dismissals.
pull_request_review:
types:
- submitted
- edited
- dismissed
# Periodic maintenance is still useful as a backstop for queue cleanup and
# other housekeeping, even though main pushes now trigger it promptly.
schedule:
- cron: "15 3 * * *"
# Allow maintainers to rerun selected checks manually when debugging bot
# behavior without waiting for another repository event.
workflow_dispatch:
inputs:
checks:
description: Comma-separated list of non-backport checks to run
required: false
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
permissions:
contents: read
issues: write
pull-requests: write
statuses: write
concurrency:
group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }}
cancel-in-progress: false
jobs:
giteabot:
if: github.repository == 'go-gitea/gitea'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# pull_request_review runs without repository secrets on fork PRs, so fall
# back to the workflow token for the non-backport checks handled here.
- uses: go-gitea/giteabot@912675d47455ac93be82d8bda4667a02b20a6fe4 # v1.0.4
with:
github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
+37 -174
View File
@@ -7,156 +7,63 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
files-changed: files-changed:
uses: ./.github/workflows/files-changed.yml uses: ./.github/workflows/files-changed.yml
permissions:
contents: read
lint-backend: lint-backend:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with: with:
go-version-file: go.mod lint-cache: "true"
check-latest: true
- run: make deps-backend deps-tools - 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 - run: make lint-backend
env:
TAGS: bindata sqlite sqlite_unlock_notify
lint-templates: lint-on-demand:
if: needs.files-changed.outputs.templates == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: astral-sh/setup-uv@v8.0.0 - uses: ./.github/actions/go-setup
- run: uv python install 3.14
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with: with:
node-version: 24 cache: "false"
cache: pnpm - uses: ./.github/actions/node-setup
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
with: with:
node-version: 24 cache: "false"
- run: make deps-frontend
- run: make lint-json
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 - run: make lint-spell
lint-go-windows: - if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true' || needs.files-changed.outputs.actions == 'true'
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with: with:
go-version-file: go.mod python-version: 3.14
check-latest: true - if: needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.yaml == 'true'
- run: make deps-backend deps-tools run: make deps-py lint-templates lint-yaml
- run: make lint-go-windows lint-go-gitea-vet
env:
TAGS: bindata sqlite sqlite_unlock_notify
GOOS: windows
GOARCH: amd64
lint-go-gogit: - if: needs.files-changed.outputs.docs == 'true' || needs.files-changed.outputs.swagger == 'true' || needs.files-changed.outputs.json == 'true'
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' run: make deps-frontend lint-md lint-swagger lint-json
needs: files-changed
runs-on: ubuntu-latest - if: needs.files-changed.outputs.actions == 'true'
permissions: run: make lint-actions
contents: read
steps: - if: needs.files-changed.outputs.shell == 'true'
- uses: actions/checkout@v6 run: make lint-shell
- 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
checks-backend: checks-backend:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- run: make deps-backend deps-tools - run: make deps-backend deps-tools
- run: make --always-make checks-backend # ensure the "go-licenses" make target runs - 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' if: needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: pnpm/action-setup@v5 - uses: ./.github/actions/node-setup
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: make deps-frontend - run: make deps-frontend
- run: make lint-frontend - run: make lint-frontend
- run: make checks-frontend - run: make checks-frontend
@@ -184,20 +84,14 @@ jobs:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with: - run: make deps-backend generate-go
go-version-file: go.mod # no frontend build here as backend should be able to build, even without any frontend files
check-latest: true # CGO is not used when cross-compile, so these steps also test if the code is compatible with CGO disabled
# 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
- name: build-backend-arm64 - name: build-backend-arm64
run: make backend # test cross compile run: go build -o gitea_linux_arm64
env: env:
GOOS: linux GOOS: linux
GOARCH: arm64 GOARCH: arm64
@@ -209,38 +103,7 @@ jobs:
GOARCH: amd64 GOARCH: amd64
TAGS: bindata gogit TAGS: bindata gogit
- name: build-backend-386 - 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: env:
GOOS: linux GOOS: linux
GOARCH: 386 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
+100 -88
View File
@@ -7,18 +7,18 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
permissions: permissions:
contents: read contents: read
test-pgsql: jobs:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' files-changed:
uses: ./.github/workflows/files-changed.yml
test-pgsql-shard-1:
if: needs.files-changed.outputs.backend == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: timeout-minutes: 50
contents: read
services: services:
pgsql: pgsql:
image: postgres:14 image: postgres:14
@@ -28,79 +28,100 @@ jobs:
ports: ports:
- "5432:5432" - "5432:5432"
ldap: ldap:
image: gitea/test-openldap:latest image: gitea/test-openldap:latest@sha256:4ac633b01d684e6b2a458cc0c8530c92f9b3702f6e040ce5f365607df34fbda0
ports: ports:
- "389:389" - "389:389"
- "636:636" - "636:636"
minio: minio:
# as github actions doesn't support "entrypoint", we need to use a non-official image # 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" # that has a custom entrypoint set to "minio server /data"
image: bitnamilegacy/minio:2023.8.31 image: bitnamilegacy/minio:2025.7.23
env: env:
MINIO_ROOT_USER: 123456 MINIO_ROOT_USER: 123456
MINIO_ROOT_PASSWORD: 12345678 MINIO_ROOT_PASSWORD: 12345678
ports: ports:
- "9000:9000" - "9000:9000"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
- uses: ./.github/actions/pgsql-shard
with: with:
go-version-file: go.mod shard: 1
check-latest: true total-shards: 2
- name: Add hosts to /etc/hosts run-migration: "true"
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts'
- run: make deps-backend test-pgsql-shard-2:
- run: make backend if: needs.files-changed.outputs.backend == 'true'
env: needs: files-changed
TAGS: bindata runs-on: ubuntu-latest
- name: run migration tests
run: make test-pgsql-migration
- name: run tests
run: make test-pgsql
timeout-minutes: 50 timeout-minutes: 50
services:
pgsql:
image: postgres:14
env: env:
TAGS: bindata gogit POSTGRES_DB: test
RACE_ENABLED: true POSTGRES_PASSWORD: postgres
TEST_TAGS: gogit ports:
TEST_LDAP: 1 - "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:
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: test-sqlite:
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true'
needs: files-changed needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- run: make deps-backend - run: make deps-backend
- run: GOEXPERIMENT='' make backend - run: make backend
env: env:
TAGS: bindata gogit sqlite sqlite_unlock_notify TAGS: bindata gogit
- name: run migration tests GOEXPERIMENT:
run: make test-sqlite-migration - run: GITEA_TEST_DATABASE=sqlite make test-migration
env:
TAGS: bindata gogit
- name: run tests - name: run tests
run: GOEXPERIMENT='' make test-sqlite run: GITEA_TEST_DATABASE=sqlite make test-integration
timeout-minutes: 50 timeout-minutes: 50
env: env:
TAGS: bindata 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
RACE_ENABLED: true GOTEST_FLAGS: -timeout=40m
TEST_TAGS: gogit sqlite sqlite_unlock_notify TAGS: bindata gogit
GOEXPERIMENT:
test-unit: 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 needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
elasticsearch: elasticsearch:
image: elasticsearch:7.5.0 image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15
env: env:
discovery.type: single-node discovery.type: single-node
xpack.security.enabled: false
xpack.ml.enabled: false
ingest.geoip.downloader.enabled: false
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
ports: ports:
- "9200:9200" - "9200:9200"
meilisearch: meilisearch:
@@ -110,7 +131,7 @@ jobs:
ports: ports:
- "7700:7700" - "7700:7700"
redis: redis:
image: redis image: redis:latest@sha256:c904002d182255b6db3cbe3a1e8ce6c187d15390c39500b59fc07181aabff7bf
options: >- # wait until redis has started options: >- # wait until redis has started
--health-cmd "redis-cli ping" --health-cmd "redis-cli ping"
--health-interval 5s --health-interval 5s
@@ -119,51 +140,49 @@ jobs:
ports: ports:
- 6379:6379 - 6379:6379
minio: minio:
image: bitnamilegacy/minio:2021.3.17 image: bitnamilegacy/minio:2025.7.23
env: env:
MINIO_ACCESS_KEY: 123456 MINIO_ROOT_USER: 123456
MINIO_SECRET_KEY: 12345678 MINIO_ROOT_PASSWORD: 12345678
ports: ports:
- "9000:9000" - "9000:9000"
devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583 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: ports:
- 10000:10000 - 10000:10000
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- name: Add hosts to /etc/hosts - 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: '[ -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 deps-backend
- run: make backend - run: make generate-go
env: env:
TAGS: bindata TAGS: bindata
- name: unit-tests - name: unit-tests
run: make unit-test-coverage test-check run: make test-backend
env: env:
GOTEST_FLAGS: -race -timeout=20m
TAGS: bindata TAGS: bindata
RACE_ENABLED: true
GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }}
- name: unit-tests-gogit - name: unit-tests-gogit
run: GOEXPERIMENT='' make unit-test-coverage test-check run: make test-backend
env: env:
GOTEST_FLAGS: -race -timeout=20m
TAGS: bindata gogit TAGS: bindata gogit
RACE_ENABLED: true GOEXPERIMENT:
GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }}
GITEA_TEST_CI_SKIP_EXTERNAL: true
- run: make test-check
test-mysql: 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 needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
mysql: mysql:
# the bitnami mysql image has more options than the official one, it's easier to customize # 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: env:
ALLOW_EMPTY_PASSWORD: true ALLOW_EMPTY_PASSWORD: true
MYSQL_DATABASE: testgitea MYSQL_DATABASE: testgitea
@@ -172,46 +191,42 @@ jobs:
options: >- options: >-
--mount type=tmpfs,destination=/bitnami/mysql/data --mount type=tmpfs,destination=/bitnami/mysql/data
elasticsearch: elasticsearch:
image: elasticsearch:7.5.0 image: docker.elastic.co/elasticsearch/elasticsearch:8.19.15
env: env:
discovery.type: single-node discovery.type: single-node
xpack.security.enabled: false
xpack.ml.enabled: false
ingest.geoip.downloader.enabled: false
ES_JAVA_OPTS: "-Xms1g -Xmx1g"
ports: ports:
- "9200:9200" - "9200:9200"
smtpimap: smtpimap:
image: tabascoterrier/docker-imap-devel:latest image: tabascoterrier/docker-imap-devel:latest@sha256:3fb7cf50b47693e7b80f6f74abea2def4d7386016931d61359864de8a0aba551
ports: ports:
- "25:25" - "25:25"
- "143:143" - "143:143"
- "587:587" - "587:587"
- "993:993" - "993:993"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- name: Add hosts to /etc/hosts - 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: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts'
- run: make deps-backend - run: make deps-backend
- run: make backend - run: make backend
env: env:
TAGS: bindata TAGS: bindata
- name: run migration tests - run: GITEA_TEST_DATABASE=mysql make test-migration
run: make test-mysql-migration
- name: run tests - name: run tests
# run: make integration-test-coverage (at the moment, no coverage is really handled) run: GITEA_TEST_DATABASE=mysql make test-integration
run: make test-mysql
env: env:
TAGS: bindata TAGS: bindata
RACE_ENABLED: true
TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200" TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200"
test-mssql: 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 needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
services: services:
mssql: mssql:
image: mcr.microsoft.com/mssql/server:2019-latest image: mcr.microsoft.com/mssql/server:2019-latest
@@ -222,24 +237,21 @@ jobs:
ports: ports:
- "1433:1433" - "1433:1433"
devstoreaccount1.azurite.local: # https://github.com/Azure/Azurite/issues/1583 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: ports:
- 10000:10000 - 10000:10000
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with:
go-version-file: go.mod
check-latest: true
- name: Add hosts to /etc/hosts - 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: '[ -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 deps-backend
- run: make backend - run: make backend
env: env:
TAGS: bindata TAGS: bindata
- run: make test-mssql-migration - run: GITEA_TEST_DATABASE=mssql make test-migration
- name: run tests - name: run tests
run: make test-mssql run: GITEA_TEST_DATABASE=mssql make test-integration
timeout-minutes: 50 timeout-minutes: 50
env: env:
TAGS: bindata TAGS: bindata
+30 -23
View File
@@ -7,34 +7,41 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
permissions: permissions:
contents: read contents: read
container: jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
# 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' if: needs.files-changed.outputs.docker == 'true'
needs: files-changed needs: [files-changed]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: docker/setup-qemu-action@v4 - uses: ./.github/actions/docker-dryrun
- uses: docker/setup-buildx-action@v4
- name: Build regular container image
uses: docker/build-push-action@v7
with: with:
context: . platform: linux/amd64
platforms: linux/amd64,linux/arm64,linux/riscv64
push: false container-arm64:
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful if: needs.files-changed.outputs.dockerfile == 'true'
- name: Build rootless container image needs: [files-changed]
uses: docker/build-push-action@v7 runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/docker-dryrun
with: with:
context: . platform: linux/arm64
push: false
platforms: linux/amd64,linux/arm64,linux/riscv64 container-riscv64:
file: Dockerfile.rootless if: needs.files-changed.outputs.dockerfile == 'true'
cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootless needs: [files-changed]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: ./.github/actions/docker-dryrun
with:
platform: linux/riscv64
+12 -18
View File
@@ -7,37 +7,31 @@ concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
permissions: permissions:
contents: read contents: read
jobs:
files-changed:
uses: ./.github/workflows/files-changed.yml
test-e2e: 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 needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-go@v6 - uses: ./.github/actions/go-setup
with: - uses: ./.github/actions/node-setup
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
- run: make deps-frontend - run: make deps-frontend
- run: make frontend - run: make frontend
- run: make deps-backend - run: make deps-backend
- run: make gitea-e2e - run: make backend
env:
TAGS: bindata
- run: make playwright - run: make playwright
- run: make test-e2e - run: make test-e2e
timeout-minutes: 10 timeout-minutes: 10
env: env:
TAGS: bindata
FORCE_COLOR: 1 FORCE_COLOR: 1
GITEA_TEST_E2E_DEBUG: 1 GITEA_TEST_E2E_DEBUG: 1
+30 -3
View File
@@ -1,8 +1,10 @@
name: labeler name: labeler
on: on:
pull_request_target: # pull_request_target is required to label PRs from forks; jobs only use pinned
types: [opened, synchronize, reopened] # 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: concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -15,6 +17,31 @@ jobs:
contents: read contents: read
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/labeler@v6 - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with: with:
sync-labels: true sync-labels: true
pr-title:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
# Base-branch checkout only: pull_request_target runs with elevated token; never run PR-head code here.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
# Labels are only synced after the title lints, so an invalid title never reaches the label diff.
- run: node ./tools/ci-tools.ts lint-pr-title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
- run: node ./tools/ci-tools.ts set-pr-labels
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ github.token }}
@@ -0,0 +1,45 @@
name: release-nightly-snapcraft
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-publish:
runs-on: ubuntu-latest
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install snapcraft
run: sudo snap install snapcraft --classic
- name: Authenticate snapcraft
shell: bash
run: snapcraft login --with <(printf '%s' "$SNAPCRAFT_STORE_CREDENTIALS")
- name: Remote build
run: |
snapcraft remote-build \
--launchpad-accept-public-upload \
--build-for=amd64,arm64,armhf
- name: List built snaps
run: find . -maxdepth 1 -type f -name '*.snap' -print
- name: Upload and release snapcraft nightly build
run: |
set -euo pipefail
for snap in ./*.snap; do
echo "Uploading $snap to edge"
snapcraft upload --release="latest/edge" "$snap"
done
+39 -27
View File
@@ -13,17 +13,18 @@ jobs:
runs-on: namespace-profile-gitea-release-binary runs-on: namespace-profile-gitea-release-binary
permissions: permissions:
contents: read contents: read
id-token: write
steps: 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 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 # 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 - run: git fetch --unshallow --quiet --tags --force
- uses: actions/setup-go@v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- uses: pnpm/action-setup@v5 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
@@ -32,34 +33,43 @@ jobs:
# xgo build # xgo build
- run: make release - run: make release
env: env:
TAGS: bindata sqlite sqlite_unlock_notify TAGS: bindata
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: import gpg key - name: import gpg key
id: import_gpg id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7 uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with: with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }} gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: sign binaries - name: sign binaries
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
run: | run: |
for f in dist/release/*; do 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 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 - name: Get cleaned branch name
id: clean_name id: clean_name
env:
REF: ${{ github.ref }}
run: | 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 "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
- name: configure aws - name: upload binaries to cloudflare r2
uses: aws-actions/configure-aws-credentials@v6 env:
with: AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
aws-region: ${{ secrets.AWS_REGION }} AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_DEFAULT_REGION: auto
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
- name: upload binaries to s3 CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: | 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: nightly-container:
runs-on: namespace-profile-gitea-release-docker runs-on: namespace-profile-gitea-release-docker
@@ -67,18 +77,20 @@ jobs:
contents: read contents: read
packages: write # to publish to ghcr.io packages: write # to publish to ghcr.io
steps: 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 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 # 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 - run: git fetch --unshallow --quiet --tags --force
- uses: docker/setup-qemu-action@v4 - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Get cleaned branch name - name: Get cleaned branch name
id: clean_name id: clean_name
env:
REF: ${{ github.ref }}
run: | 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" echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
- uses: docker/metadata-action@v6 - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta id: meta
with: with:
images: |- images: |-
@@ -88,7 +100,7 @@ jobs:
type=raw,value=${{ steps.clean_name.outputs.branch }} type=raw,value=${{ steps.clean_name.outputs.branch }}
annotations: | annotations: |
org.opencontainers.image.authors="maintainers@gitea.io" org.opencontainers.image.authors="maintainers@gitea.io"
- uses: docker/metadata-action@v6 - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta_rootless id: meta_rootless
with: with:
images: |- images: |-
@@ -102,18 +114,18 @@ jobs:
annotations: | annotations: |
org.opencontainers.image.authors="maintainers@gitea.io" org.opencontainers.image.authors="maintainers@gitea.io"
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR using PAT - name: Login to GHCR using PAT
uses: docker/login-action@v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: build regular docker image - name: build regular docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/riscv64 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-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 cache-to: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful,mode=max
- name: build rootless docker image - name: build rootless docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/riscv64 platforms: linux/amd64,linux/arm64,linux/riscv64
+40 -29
View File
@@ -14,17 +14,18 @@ jobs:
runs-on: namespace-profile-gitea-release-binary runs-on: namespace-profile-gitea-release-binary
permissions: permissions:
contents: read contents: read
id-token: write
steps: 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 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 # 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 - run: git fetch --unshallow --quiet --tags --force
- uses: actions/setup-go@v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- uses: pnpm/action-setup@v5 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
@@ -33,43 +34,53 @@ jobs:
# xgo build # xgo build
- run: make release - run: make release
env: env:
TAGS: bindata sqlite sqlite_unlock_notify TAGS: bindata
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: import gpg key - name: import gpg key
id: import_gpg id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7 uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with: with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }} gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: sign binaries - name: sign binaries
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
run: | run: |
for f in dist/release/*; do 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 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 - name: Get cleaned branch name
id: clean_name id: clean_name
env:
REF: ${{ github.ref }}
run: | 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 "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: configure aws - name: upload binaries to cloudflare r2
uses: aws-actions/configure-aws-credentials@v6 env:
with: AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
aws-region: ${{ secrets.AWS_REGION }} AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_DEFAULT_REGION: auto
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
- name: upload binaries to s3 CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: | 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 - 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: with:
gh-cli-version: 2.39.1 gh-cli-version: 2.39.1
- name: create github release - name: create github release
run: |
gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --draft --notes-from-tag dist/release/*
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$TAG" --title "$TAG" --draft --notes-from-tag dist/release/*
container: container:
runs-on: namespace-profile-gitea-release-docker runs-on: namespace-profile-gitea-release-docker
@@ -77,13 +88,13 @@ jobs:
contents: read contents: read
packages: write # to publish to ghcr.io packages: write # to publish to ghcr.io
steps: 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 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 # 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 - run: git fetch --unshallow --quiet --tags --force
- uses: docker/setup-qemu-action@v4 - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- uses: docker/metadata-action@v6 - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta id: meta
with: with:
images: |- images: |-
@@ -96,7 +107,7 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
annotations: | annotations: |
org.opencontainers.image.authors="maintainers@gitea.io" org.opencontainers.image.authors="maintainers@gitea.io"
- uses: docker/metadata-action@v6 - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta_rootless id: meta_rootless
with: with:
images: |- images: |-
@@ -112,18 +123,18 @@ jobs:
annotations: | annotations: |
org.opencontainers.image.authors="maintainers@gitea.io" org.opencontainers.image.authors="maintainers@gitea.io"
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR using PAT - name: Login to GHCR using PAT
uses: docker/login-action@v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: build regular container image - name: build regular container image
uses: docker/build-push-action@v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/riscv64 platforms: linux/amd64,linux/arm64,linux/riscv64
@@ -131,7 +142,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }} annotations: ${{ steps.meta.outputs.annotations }}
- name: build rootless container image - name: build rootless container image
uses: docker/build-push-action@v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/riscv64 platforms: linux/amd64,linux/arm64,linux/riscv64
@@ -17,17 +17,18 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write # to publish to ghcr.io packages: write # to publish to ghcr.io
id-token: write
steps: 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 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 # 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 - run: git fetch --unshallow --quiet --tags --force
- uses: actions/setup-go@v6 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with: with:
go-version-file: go.mod go-version-file: go.mod
check-latest: true check-latest: true
- uses: pnpm/action-setup@v5 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
@@ -36,43 +37,53 @@ jobs:
# xgo build # xgo build
- run: make release - run: make release
env: env:
TAGS: bindata sqlite sqlite_unlock_notify TAGS: bindata
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: import gpg key - name: import gpg key
id: import_gpg id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7 uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
with: with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }} gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: sign binaries - name: sign binaries
env:
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
run: | run: |
for f in dist/release/*; do 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 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 - name: Get cleaned branch name
id: clean_name id: clean_name
env:
REF: ${{ github.ref }}
run: | 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 "Cleaned name is ${REF_NAME}"
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
- name: configure aws - name: upload binaries to cloudflare r2
uses: aws-actions/configure-aws-credentials@v6 env:
with: AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
aws-region: ${{ secrets.AWS_REGION }} AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_DEFAULT_REGION: auto
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
- name: upload binaries to s3 CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
BRANCH: ${{ steps.clean_name.outputs.branch }}
run: | 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 - 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: with:
gh-cli-version: 2.39.1 gh-cli-version: 2.39.1
- name: create github release - name: create github release
run: |
gh release create ${{ github.ref_name }} --title ${{ github.ref_name }} --notes-from-tag dist/release/*
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release create "$TAG" --title "$TAG" --notes-from-tag dist/release/*
container: container:
runs-on: namespace-profile-gitea-release-docker runs-on: namespace-profile-gitea-release-docker
@@ -80,13 +91,13 @@ jobs:
contents: read contents: read
packages: write # to publish to ghcr.io packages: write # to publish to ghcr.io
steps: 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 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 # 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 - run: git fetch --unshallow --quiet --tags --force
- uses: docker/setup-qemu-action@v4 - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- uses: docker/metadata-action@v6 - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta id: meta
with: with:
images: |- images: |-
@@ -103,7 +114,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
annotations: | annotations: |
org.opencontainers.image.authors="maintainers@gitea.io" org.opencontainers.image.authors="maintainers@gitea.io"
- uses: docker/metadata-action@v6 - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
id: meta_rootless id: meta_rootless
with: with:
images: |- images: |-
@@ -124,18 +135,18 @@ jobs:
annotations: | annotations: |
org.opencontainers.image.authors="maintainers@gitea.io" org.opencontainers.image.authors="maintainers@gitea.io"
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR using PAT - name: Login to GHCR using PAT
uses: docker/login-action@v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: build regular container image - name: build regular container image
uses: docker/build-push-action@v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/riscv64 platforms: linux/amd64,linux/arm64,linux/riscv64
@@ -143,7 +154,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }} annotations: ${{ steps.meta.outputs.annotations }}
- name: build rootless container image - name: build rootless container image
uses: docker/build-push-action@v7 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/riscv64 platforms: linux/amd64,linux/arm64,linux/riscv64
-3
View File
@@ -55,10 +55,7 @@ cpu.out
*.log.*.gz *.log.*.gz
/gitea /gitea
/gitea-e2e
/gitea-vet
/debug /debug
/integrations.test
/bin /bin
/dist /dist
+18 -5
View File
@@ -43,7 +43,7 @@ linters:
desc: use os or io instead desc: use os or io instead
- pkg: golang.org/x/exp - pkg: golang.org/x/exp
desc: it's experimental and unreliable 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 desc: do not use the internal package, use AddXxx function instead
- pkg: gopkg.in/ini.v1 - pkg: gopkg.in/ini.v1
desc: do not use the ini package, use gitea's config system instead 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 desc: do not use the go-chi cache package, use gitea's cache system
- pkg: github.com/pkg/errors - pkg: github.com/pkg/errors
desc: use builtin errors package instead 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: nolintlint:
allow-unused: false allow-unused: false
require-explanation: true require-explanation: true
@@ -99,6 +107,7 @@ linters:
- -QF1008 - -QF1008
testifylint: testifylint:
disable: disable:
- empty
- go-require - go-require
- require-error - require-error
usetesting: usetesting:
@@ -158,9 +167,16 @@ issues:
max-same-issues: 0 max-same-issues: 0
formatters: formatters:
enable: enable:
- gofmt - gci
- gofumpt - gofumpt
settings: settings:
gci:
custom-order: true
sections:
- standard
- prefix(gitea.dev)
- blank
- default
gofumpt: gofumpt:
extra-rules: true extra-rules: true
exclusions: exclusions:
@@ -170,9 +186,6 @@ formatters:
- .venv - .venv
- public - public
- web_src - web_src
- third_party$
- builtin$
- examples$
run: run:
timeout: 10m timeout: 10m
-7
View File
@@ -1,7 +0,0 @@
audit=false
fund=false
update-notifier=false
save-exact=true
auto-install-peers=true
dedupe-peer-dependents=false
enable-pre-post-scripts=true
+1
View File
@@ -0,0 +1 @@
disable=SC1091,SC2001,SC2002,SC2016,SC2028,SC2046,SC2124,SC2128,SC2129,SC2154,SC2155,SC2164,SC2181,SC2207
+502 -4
View File
@@ -4,6 +4,504 @@ This changelog goes through the changes that have been made in each release
without substantial changes to our git log; to see the highlights of what has without substantial changes to our git log; to see the highlights of what has
been added to each release, please refer to the [blog](https://blog.gitea.com). been added to each release, please refer to the [blog](https://blog.gitea.com).
## [1.27.3](https://github.com/go-gitea/gitea/releases/tag/v1.27.3) - 2026-08-29
* SECURITY
* fix(packages): restrict/limited/token-scope access (#39041, #39043, #39044, #39047, #39046) (#39058)
* fix(attachments): enforce owning repository path (#39048) (#39077)
* fix(markup): enforce same-repository issue access (#39045) (#39054)
* fix(actions): verify raw artifact signatures first (#39049) (#39053)
* fix(api): hide limited users from restricted viewers (#39004) (#39039)
* fix(repo): limit gitignore template selections (#39027) (#39040)
* fix(migrations): cancel GitLab version probes (#39023) (#39035)
* fix(packages): limit Swift package manifests (#39025) (#39032)
* fix(migrations): bound OneDev version responses (#39024) (#39033)
* fix(packages): limit Maven checksum uploads (#39028) (#39031)
* fix(packages): bound Alpine metadata entries (#39026) (#39029)
* fix(actions): enforce fork pull request trust boundaries (#39005) (#39018)
* fix(git): restrict hook permissions (#39008) (#39016)
* fix(api): enforce repository creation token authorization (#39007) (#39014)
* fix(api): enforce public-only scope for compare heads (#39006) (#39013)
* fix(repo): hide repositories of hidden owners (#39009) (#39012)
* fix: avoid enumerating every public repository in issue search (#38992) (#39000)
* refactor: private endpoints (#38964) (#38965)
* ENHANCEMENTS
* enhance: add permalinks to pull request reviews (#38849) (#39036)
* BUGFIXES
* fix: add missing query parameters on runner list page (#39163)
* fix(actions): keep step-level continue-on-error expressions unevaluated (#39141) (#39148)
* fix(packages): preserve SemVer prerelease identifiers in Swift Registry (#39156) (#39158)
* fix(repo): prevent MarkAsBrokenEmpty when repository is being migrated (#39091) (#39092)
* fix(asymkey): do not verify OpenPGP signatures with an SSH instance key (#39073) (#39086)
* fix(pull): keep the merged state in sync with git (#39062) (#39118)
* fix(pull): name the head repository in default compare links (#39075) (#39079)
* fix(git): parse co-author trailers that are not RFC 5322 addresses (#39076) (#39081)
* fix(actions): show "Complete job" logs when the last step is skipped (#38939) (#39003)
* fix(actions): Fix how jobs in matrixes are grouped (#38980) (#38998)
* fix: resolve YAML anchors and aliases in Actions workflows (#38984) (#38996)
* fix: honor environment variables during install (#38974) (#38976)
* fix: grant limited-org unit read access to authenticated non-members (#38871) (#38963)
* fix: allow anonymous theme switching when REQUIRE_SIGNIN_VIEW is set (#38956) (#38961)
* fix(actions): drop wrapper span around the action status icon (#38957) (#38959)
* fix(issues): sort scoped labels by exclusive order in dropdowns (#38893) (#38954)
* fix(indexer): correct bleve indexer token filters (#38853) (#38951)
* fix: make "login_name" field optional for API edit user (#38917) (#38945)
* fix(actions): reject non-mapping matrix include/exclude (#38933)
* fix(ui): respect FEED_PAGING_NUM on the dashboard feed (#38935) (#38936)
* MISC
* chore: repo compare link (#39088) (#39119)
* ci: remove AWS S3 uploads from release workflows (#38928) (#38929)
* chore: Pre-register a builtin OAuth2 application for the official Gitea mobile app (#38880) (#38922)
## [1.27.2](https://github.com/go-gitea/gitea/releases/tag/v1.27.2) - 2026-08-14
* SECURITY
* Fix: update collaborator access mode and httpsign (#38894, #38862) (#38895)
* Refactor: external render (#38885) (#38898)
* Fix(actions): resolve pull_request_target reusable workflows at the base commit (#38886) (#38897)
* Refactor: markup render (#38864) (#38869)
* Fix(deps): update dependency mermaid to v11.16.1 (#38816)
* Fix(auth): set WebAuthn user verification per request (#38805) (#38810)
* Fix: render highlight language (#38793) (#38795)
* ENHANCEMENTS
* enhance: add missing npm package metadata properties (#38826) (#38831)
* BUGFIXES
* fix(actions): keep github.event.inputs as strings for workflow_dispatch (#38899) (#38908)
* fix(actions): let a rerun of selected jobs read the previous attempt's artifacts (#38857) (#38901)
* fix(lfs): accept successful transfer responses (#38866) (#38875)
* fix(packages): ignore nested Package.swift (#38788) (#38836)
* fix: drop newline-bearing member names in arch ParsePackage (#38102) (#38830)
* fix(storage): fix Azure Blob dump failing with file does not exist (#38814) (#38828)
* fix(migration): migration deletion returned json redirection (#38796) (#38825)
* fix(ui): change underlines to default browser style (#38819) (#38823)
* fix(actions): allow cancelling runs without running jobs (#35842) (#38812)
* fix(actions): evaluate each `${{ }}` part on its own (#38754) (#38797)
* fix(actions): write an action task report in one transaction (#38792) (#38794)
* fix: markup link (#38764) (#38765)
* fix: set a minio part size when the content size is unknown (#38753) (#38755)
* fix: bad path escape in subpath archive download (#38749) (#38750)
* fix: remove the pull merge box from UI when the refreshed page doesn't contain it (#38742) (#38744)
* fix(markdown): fix double strikethough on code (#38707) (#38729)
* fix(lfs): failed upload deletes a concurrent upload's meta object (#38693) (#38722)
* fix: correct full url when using sub-path (#38712) (#38716)
* fix: avoid markup render panic (#38698) (#38703)
* fix(ui): too many participants shown in commit avatar stacks (#38689) (#38700)
* fix: support HEAD requests on Alpine registry APKINDEX.tar.gz (#38686) (#38688)
* fix(migrations): use all configured GitHub tokens (#38841) (#38846)
## [1.27.1](https://github.com/go-gitea/gitea/releases/tag/v1.27.1) - 2026-07-27
* SECURITY
* fix(oauth2): enforce mandatory 2FA policy on OAuth2 authorize/grant endpoints (#38591) (#38606)
* API
* fix(api): align Swagger schemas for UserSettings and TopicListResponse (#38590) (#38592)
* ENHANCEMENTS
* enhance: improve diff contrast in light and dark themes (#37477) (#38574)
* BUGFIXES
* fix: skip OIDC end-session after password login for OAuth2 users (#38439) (#38666)
* fix: make Actions log parser support multiple line message encoding (#38659) (#38664)
* fix(actions): use base branch ref for pull_request_target context (#38636) (#38657)
* fix(actions): skip already-approved runs in `ApproveRuns` (#38653) (#38654)
* fix: orgmode render include path (#38642) (#38645)
* fix(actions): cancel tasks immediately when the runner stopped reporting (#38616) (#38644)
* fix(issues): fix label bulk-load key and reduce log noise in LoadLabel (#38632) (#38643)
* fix(actions): improve runner list status sorting, labels and task job links (#38586) (#38633)
* fix(actions): correctness and hardening fixes (#38518) (#38631)
* fix(repo): prevent double-write redirect collisions on dependency errors, fix ui (#38627) (#38628)
* fix: delete repo-scoped rows of seven more tables when deleting a repository (#38534) (#38618)
* fix(webhook): remove slack channel name check (#38608) (#38612)
* fix: download dropdown menu clipped on the branches page (#38604) (#38609)
* fix(project): prevent database mutations on invalid MoveIssues payload (#38600) (#38602)
* fix(actions): make SingleWorkflow.Marshal round-trip multi-line run blocks (stop silent job stranding) (#38520) (#38599)
* fix(file-tree): handle submodule links and missing view container (#38033) (#38589)
* fix(actions): fail unexpandable reusable workflow callers and decouple the job emitter's cross-run processing (#38565) (#38587)
* fix: keep serving valid ACME cert when renewal fails at startup (#38554) (#38583)
* fix: branch protection user list (#38570) (#38584)
* fix(pulls): respect diff.orderFile in diff file tree (#38566) (#38578)
* fix(issue): make issue action (issue list batch operation) elements have correct attributes (#38575) (#38580)
* fix(actions): support `matrix` when evaluating workflow `if` expression (#38474) (#38557)
* fix(actions): align status icon span for Safari rendering (#38558) (#38562)
* fix: revert git clone http redirection forbidden (#38530) (#38545)
* fix: clean up orphaned user-keyed tables in deleteUser (#38511) (#38514)
* fix(actions): coerce workflow_dispatch boolean inputs to native types (#38472) (#38521)
* fix: make the merge box button red if some checks fail (#38508) (#38516)
* fix(pull): sign the commit when updating a branch by merge (#38441) (#38499)
* fix: make commit message merge correctly (#38490) (#38502)
* fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
* fix(actions): make `cancelled()` work in job `if` evaluation (#38495) (#38497)
* fix(actions): show retention info on hover for expired artifacts (#38477) (#38493)
* fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475) (#38492)
* fix: full file highlighting for git diff with CR char (#38484) (#38491)
* fix(packages): serve noarch Alpine index for any requested architecture (#38479) (#38486)
* fix: 500 error when updating user visibility (#38480) (#38483)
* fix(actions): make job list item fully clickable (#38462) (#38471)
* fix: mail template for push event (#38467) (#38468)
* fix: make "test push webhook" always work (#38425) (#38455)
* fix(actions): prevent bulk actions from affecting all runners (#38453) (#38457)
* fix(org): align follow button and wrap description (#38448) (#38454)
* fix(actions): populate `github.event` for scheduled runs (#38446) (#38452)
* MISC
* refactor: git patch apply (#38637) (#38638)
## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
* BREAKING
* Feat(actions)!: improve support for reusable workflows (#37478)
* Use Content-Security-Policy: script nonce (#37232)
* SECURITY
* Fix: various security fixes (#38406) (#38426)
* Fix(security): harden access checks and migration validation (#38324) (#38400)
* Fix: enforce public-only token scope and harden push options / locale parsing (#38323) (#38399)
* Fix(pull): re-evaluate review official flag on target branch change (#38319) (#38402)
* Fix(api): stop leaking private repo metadata after access revocation (#38321) (#38390)
* Fix(lfs): require proof of possession for cross-repo objects (#38322) (#38389)
* Fix(mirror): disable HTTP redirects on pull mirror sync (#38320) (#38367)
* Fix: golang html template url escaping (#38363) (#38369)
* Fix(release): validate web attachment renames against allowed types (#38314) (#38328)
* Fix(release): gate draft release attachments on web download endpoints (#38318) (#38325)
* Fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 [security] (#37786)
* Fix(oauth): restrict introspection to the token's client (#38042)
* Fix(api): don't expose private org membership via public_members (#38145)
* Fix(actions): deny fork-PR cross-repo access via collaborative owner (#38214)
* Fix(migrations): prevent path traversal in repository restore (#38215)
* FEATURES
* Feat(actions): add workflow status badge modal (#38196)
* Feat(actions): support owner-level and global scoped workflows (#38154)
* Feat(api): support ref suffixes in compare (#38148)
* Feat(actions): implement `jobs.<job_id>.continue-on-error` (#38100)
* Feat(actions): show run status on browser tab favicon (#38071)
* Feat(api): add token introspection and self-deletion endpoint (#37995)
* Feat(api): add q parameter to list branches API for server-side filtering (#37982)
* Feat(repo): split repository creation limit into user and org scopes (#37872)
* Feat(actions): bulk delete, disable and enable runners in admin UI (#37869)
* Feat(actions): List workflows that were executed once but got removed from the default branch (#37835)
* Feat(org): add team visibility so org members can discover teams (#37680)
* Feat: add raw diff/patch endpoint for repository comparisons (#37632)
* Feat: Add avatar stacks (#37594)
* Feat(actions): add job summaries (GITHUB_STEP_SUMMARY) (#37500)
* Feat(web): Add Jupyter Notebook (.ipynb) Rendering Support (#37433)
* Support for Custom URI Schemes in OAuth2 Redirect URIs (#37356)
* Feat(orgs): Add search bar for organization members tab page (#37347)
* Feat(api): Add assignees APIs (#37330)
* Feat(api): Add GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs (#37196)
* Serve OpenAPI 3.0 spec at /openapi.v1.json (#37038)
* Add project column picker to issue and pull request sidebar (#37037)
* Allow multiple projects per issue and pull requests (#36784)
* Feat(ui): add "follow rename" to file commit history list (#34994)
* Feat(ssh): auto generate additional ssh keys (#33974)
* ENHANCEMENTS
* Enhance(actions): only create filtered-out workflow commit status for required contexts (#38371) (#38385)
* Enhance: allow builtin default git config options to be overridden (#38172)
* Enhance: allow MathML core elements (#38034)
* Enhance(markup): improve issue title rendering (#37908)
* Enhance(actions): set descriptive browser tab title on run view (#37870)
* Enhance: Migrate remaining gopkg.in/yaml.v3 usages to go.yaml.in/yaml/v4 (#37866)
* Enhance(actions): show workflow name from YAML instead of filename (#37833)
* Feat(actions): add before/after to PR synchronize event payload (#37827)
* Enhance(actions): add branch filters to run list (#37826)
* Enhance(actions): Make Summary UI more beautiful with more infos (#37824)
* Feat: add copy button to action step header, improve other copy buttons (#37744)
* Fix(icon): use repo-forked icon to display forks count (#37731)
* Feat(api): add sort and order query parameters to job list endpoints (#37672)
* Feat(api): add last_sync to repository API (#37566)
* Enhance: Adjust Workflow Graph styling (#37497)
* Improve code editor text selection and clean up lint enablement (#37474)
* Add mirror auth updates to repo edit API and settings (#37468)
* Replace `olivere/elastic` with REST API client, add OpenSearch support (#37411)
* Feat: Add default PR branch update style setting (#37410)
* Fix inconsistent disabled styling on logged-out repo header buttons (#37406)
* Allow fast-forward-only merge when signed commits are required (#37335)
* Enhance styling in actions page (#37323)
* Fix: improve actions status icons and texts (#37206)
* Make Markdown fenced code block work with more syntaxes (#37154)
* Fix: Sort action run jobs by JobID and Name with matrix examples (#37046)
* Add API endpoint to reply to pull request review comments (#36683)
* PERFORMANCE
* Perf(actions): debounce runner heartbeat writes and throttle task picks (#38281) (#38368)
* Perf(web): sort the action_run query by a repo-scoped index when possible (#38155)
* Perf: Various performance regression fixes (#38078)
* Perf: extend action `c_u` index to include `created_unix` for faster dashboard feeds (#38076)
* Batch-load related data in actions run, job, and task API endpoints (#37032)
* BUGFIXES
* Fix(util): reject invalid characters between time-estimate units (#38416) (#38423)
* Fix: represent a deleted assignee team as a Ghost team (#38413) (#38419)
* Fix(turnstile): route CAPTCHA verification through the configured proxy (#38412) (#38420)
* Fix: refresh pull request merge box when the commit status is pending (#38410) (#38411)
* Fix: actions task state concurrent update (#38405) (#38409)
* Fix(actions): keep workflow run trailing on one row with long branch names (#38382) (#38403)
* Fix(web): use locale-aware date formatting for contribution calendar tooltips (#38398) (#38401)
* Fix: co-author detection (#38392) (#38397)
* Fix: incorrect co-author detection on commit page (#38386) (#38387)
* Fix(ui): restore commits table column widths (#38379) (#38383)
* Fix: minio init check (#38355) (#38361)
* Fix: org project view assignee list (#38357) (#38360)
* Fix(actions): release claimed task if context is cancelled during `FetchTask` (#38343) (#38347)
* Fix(actions): make runner list pagination order deterministic (#38313) (#38327)
* Fix: Improve since/until when counting commits for X-Total-Count (#38243) (#38304)
* Fix(actions): prevent chevron overlap with log text when timestamps are enabled (#38227) (#38307)
* Fix(workflows): branch protection status checks fail when workflow uses on: paths filter (#38237) (#38302)
* Fix(oauth2): persist linkAccountData during auto-link 2FA flow (#38274) (#38295)
* Fix(actions): allow Actions bot to push to protected branches (#38284) (#38293)
* Fix(actions): include all aggregable run statuses in status filter (#38280) (#38287)
* Fix(archiver): use serializable repo-archive queue payload (#38273) (#38283)
* Fix: update npm dependencies, fix misc issues (#38257)
* Fix(api): respect since/until when counting commits for X-Total-Count (#38204)
* Fix: codemirror regressions (#38248)
* Fix(api): support HEAD requests on all API GET endpoints (#38245)
* Fix(actions): Cleanup workflow status badge code (#38241)
* Fix(web): Correctly align the "disabled" label on larger workflow names (#38240)
* Fix(actions): don't swallow HTML entities into linkified URLs (#38239)
* Fix(packages): accept npm "repository" and "bin" in string form (#38236)
* Fix(actions): fix 500 error when canceling a canceling task (#38223)
* Fix(deps): update module golang.org/x/image to v0.43.0 [security] (#38219)
* Fix(mssql): convert legacy DATETIME columns to DATETIME2 (#38216)
* Fix(api): deny private org member enumeration via /members (#38213)
* Fix(actions): ensure all waiting jobs get runners in large workflows (#38200)
* Fix(deps): update go dependencies (#38194)
* Fix(deps): update npm dependencies (#38193)
* Fix(cli): default must-change-password to false for bot users (#38175)
* Fix(actions): show run index in run view and fix summary graph height (#38165)
* Fix: csp (#38162)
* Fix(deps): update npm dependencies (#38123)
* Fix(mssql): expand legacy issue and comment long-text columns (#38120)
* Fix(packages): validate debian distribution and component names (#38116)
* Fix(packages): validate module version in goproxy ParsePackage (#38104)
* Fix(deps): update dependency esbuild to v0.28.1 [security] (#38097)
* Fix: git push hook post receive (#38089)
* Fix(ui): prevent commit status popup overflowing its row (#38081)
* Fix: validate gem name in rubygems parseMetadataFile (#38061)
* Fix: commit display name (#38057)
* Fix: csp regressions (#38047)
* Fix: api error message (#38031)
* Fix(deps): update npm dependencies (#38029)
* Fix: pgsql lint (#38022)
* Fix(indexer): fix assignee filters in issue search (#38021)
* Fix: various dropdown problems (#38020)
* Fix: refactor git error handling and make archive streaming handle non-existing commit id (#38007)
* Fix: raise git required version to 2.13 (#37996)
* Fix: remove "no-transfrom" from the cache-control header (#37985)
* Fix(deps): update module github.com/google/go-github/v87 to v88 (#37971)
* Fix: use committer time where ever possible as default (#37969)
* Fix(deps): update npm dependencies, remove nolyfill (#37968)
* Fix(deps): update go dependencies (#37967)
* Fix(pull): preserve squash message trailers and additional commit messages (#37954)
* Fix(deps): update module golang.org/x/image to v0.41.0 [security] (#37904)
* Fix: support ##[command] log prefix in action run UI (#37882)
* Fix(deps): update module github.com/google/go-github/v86 to v87 (#37845)
* Fix(deps): update npm dependencies (#37844)
* Fix(deps): update go dependencies (#37841)
* Fix(frontend): resolve Vite assets by manifest source path (#37836)
* Fix(locales): Replace hardcoded strings (#37788)
* Fix(packages): render markdown links relative to linked repo (#37676)
* Fix: persist mirror repository metadata (#37519)
* Fix cmd tests by mocking builtin paths (#37369)
* Add `form-fetch-action` to some forms, fix "fetch action" resp bug (#37305)
* Feat: execute post run cleanup when workflow is cancelled (#37275)
* Fix `relative-time` error and improve global error handler (#37241)
* Refactor flash message and remove SanitizeHTML template func (#37179)
* TESTING
* Test(e2e): fix race in pdf file render test (#38380) (#38381)
* Test: compare key file contents instead of `FileInfo` in `TestInitKeys` (#38330) (#38331)
* Test: speed up two tests (#37905)
* Test: Fix random failure test (#37887)
* Test: fix flaky `issue-comment` close test (#37880)
* Test: enable WAL for sqlite integration tests (#37861)
* Test: fix flaky `TestResourceIndex` and reduce its runtime (#37847)
* Test: run `TestAPIRepoMigrate` offline via a local clone source (#37817)
* Ci: shard tests and reduce redundant work (#37618)
* Test(e2e): run playwright via container (#37300)
* Remove external service dependencies in migration tests (#36866)
* BUILD
* Fix(actions): authenticate snapcraft before nightly remote build (#38252)
* Ci: cap Elasticsearch heap in db-tests (#37816)
* Build(snap): publish nightly version to snapcraft via actions (#37814)
* Ci: split pgsql shards into plain jobs, dedupe setup actions (#37802)
* Ci: narrow files-changed frontend filter (#37749)
* Ci: add `zizmor` to `lint-actions` (#37720)
* Chore: clean up "contrib" dir (#37690)
* Fix: snap build (main branch) (#37685)
* Ci: Also lint json5 files (#37659)
* Feat(editor): broaden language detection in web code editor (#37619)
* Build: update pnpm to v11 (#37591)
* Refactor(deps): migrate from `nektos/act` fork to `gitea/runner` (#37557)
* Refactor: lint bare `fill`/`stroke` colors, add vars for git graph color series (#37543)
* Update go js py dependencies (#37525)
* Ci: lint PR titles with commitlint (#37498)
* Chore: upgrade Go version in devcontainer image to 1.26 (#37374)
* Update GitHub Actions to latest major versions (#37313)
* Update go js dependencies (#37312)
* Fail vite build on rolldown warnings via NODE_ENV=test (#37270)
* Remove htmx (#37224)
* Replace custom Go formatter with `golangci-lint fmt` (#37194)
* Refactor htmx and fetch-action related code (#37186)
* Integrate renovate bot for all dependency updates (#37050)
* Build(sign): move to sigstore (#38250)
* DOCS
* Docs: update changelog for 1.26.3 & 1.26.4 (#38178)
* Docs: fix duplicated word in foreachref doc comment (#38161)
* Docs: Clarify criteria for becoming a merger (#38113)
* Docs: Publish TOC Election Result 2026 (#38111)
* Docs: mark openapi3 as autogenerated in attributes (#37963)
* Docs: add development setup guide (#37960)
* MISC
* Revert(sign): restore gpg (#38251)
* Refactor: replace legacy `delete-button` with `link-action` (#38143)
* Refactor(actions): read runner capabilities from proto field (#38068)
* Refactor(api): clarify APIError message usage and fix legacy lint error (#38012)
* Refactor: Use db.Get[] instead of db.GetEngine(ctx).Get(bean) to avoid zero value fetching wrong database record (#37977)
* Fix(deps): update go dependencies (#37851)
* Ci: Fix sync PR labels from the conventional-commit title (#37784) (#37825)
* Ci: tweak `files-changed`, add `free-disk-space` (#37819)
* Fix(deps): update module golang.org/x/crypto to v0.52.0 [security] (#37806)
* Test(e2e): add comment, release, star, PR and fork tests (#37800)
* Chore: simplify issue and pull request templates (#37799)
* Chore: Update giteabot to fix failure when backport (#37789)
* Fix(api): handle partial failures in push mirror synchronization gracefully (#37782)
* Fix(deps): update module gitlab.com/gitlab-org/api/client-go/v2 to v2.26.0 (#37771)
* Ci: split giteabot workflow (#37770)
* Fix(deps): update npm dependencies (#37768)
* Refactor(waitgroup): replace Add/Done goroutines with WaitGroup.Go (#37764)
* Fix(deps): update module google.golang.org/grpc to v1.81.1 (#37762)
* Ci: fix cache-related issues (#37761)
* Chore: fix tests (#37760)
* Fix(deps): update module github.com/google/go-github/v85 to v86 (#37754)
* Fix(deps): update npm dependencies (#37753)
* Fix(deps): update go dependencies (#37752)
* Chore(deps): update action dependencies (#37751)
* Fix(markup): wrap indented code blocks for the code-copy button (#37748)
* Chore(db): introduce db.Session and db.EngineMigration interfaces (#37746)
* Feat(web): also display PR counts in repo list (#37739)
* Refactor(glob): use strings.Builder for regexp compilation (#37730)
* Chore(doctor): remove four obsolete doctor check implementations (#37728)
* Refactor(org): simplify owner-team org repo creation logic (#37727)
* Refactor: move `workflowpattern` into `modules/actions` (#37717)
* Chore: clean up tests (#37715)
* Style: misc UI fixes (#37691)
* Ci: add shellcheck linter (#37682)
* Fix: catch and fix more lint problems (#37674)
* Fix(deps): update dependency mermaid to v11.15.0 [security], add e2e test (#37662)
* Fix(deps): update npm dependencies (#37647)
* Ci(renovate): update Go import paths on major bumps (#37641)
* Fix(deps): update go dependencies (major) (#37639)
* Chore(deps): update action dependencies (major) (#37638)
* Fix(deps): update module code.gitea.io/sdk/gitea to v0.25.0 (#37637)
* Fix(deps): update npm dependencies (#37636)
* Refactor(log): replace log.Critical with log.Error (#37624)
* Build(deps): bump fast-uri from 3.1.0 to 3.1.2 (#37616)
* Feat(oauth): Support AWS Cognito OAuth2 provider (#37607)
* Chore(deps): update action dependencies (#37603)
* Ci: allow `chore` type in PR title lint (#37575)
* Refactor: only reset a database table when the table's data was changed (#37573)
* Ci: increase renovate frequency and fix RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS (#37565)
* Refactor: use modernc sqlite driver as default (#37562)
* Docs: fix 4 typos in CHANGELOG.md (#37549)
* Fix(deps): update go dependencies (#37541)
* Chore(deps): update action dependencies (#37540)
* Refactor pull request view (6) (#37522)
* Fix: redirect early CLI console logger to stderr (#37507)
* Refactor "flex-list" to "flex-divided-list" (#37505)
* Refactor compare diff/pull page (1) (#37481)
* Refactor pull request view (4) (#37451)
* Update 1.26.1 changelog in main (#37442)
* Refactor: use named `Permission` field in `Repository` struct instead of anonymous embedding (#37441)
* Refactor: serve site manifest via `/assets/site-manifest.json` endpoint (#37405)
* Remove IsValidExternalURL/IsAPIURL and use IsValidURL at call sites (#37364)
* Update `Block a user` form (#37359)
* Move review request functions to a standalone file (#37358)
* Feat(security): set X-Content-Type-Options: nosniff by default (#37354)
* Enable strict TypeScript, add `errorMessage` helper (#37292)
* Refactor frontend `tw-justify-between` layouts to `flex-left-right` (#37291)
* Update Nix flake (#37284)
* Fix Repository transferring page (#37277)
* Remove `SubmitEvent` polyfill (#37276)
* Remove dead code identified by `deadcode` tool (#37271)
* Upgrade go-git to v5.18.0 (#37268)
* Don't add useless labels which will bother changelog generation (#37267)
* Move heatmap to first-party code (#37262)
* Tests/integration: simplify code (#37249)
* Add pagination and search box to org teams list (#37245)
* Remove error returns from crypto random helpers and callers (#37240)
* Add `ExternalIDClaim` option for OAuth2 OIDC auth source (#37229)
* Refactor: simplify ParseCatFileTreeLine and catBatchParseTreeEntries (#37210)
* Refactor "htmx" to "fetch action" (#37208)
* Update go js py dependencies (#37204)
* Add comment for the design of "user activity time" (#37195)
* Remove outdated RunUser logic (#37180)
* Models/fixtures: add "DO NOT add more test data" comment to all yml fixture files (#37150)
* Update javascript dependencies (#37142)
* Update go dependencies (#37141)
* Frontport changelog of v1.26.0-rc0 (#37138)
* Introduce `ActionRunAttempt` to represent each execution of a run (#37119)
* Workflow Artifact Info Hover (#37100)
* Extend issue context popup beyond markdown content (#36908)
* Add bulk repository deletion for organizations (#36763)
* Feat: Add bypass allowlist for branch protection (#36514)
## [1.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21
* SECURITY
* fix(auth): do not auto-reactivate disabled users on OAuth2 callback (#38009) (#38183)
* BUGFIXES
* fix: walk git log context error handling (#38182) (#38185)
## [1.26.3](https://github.com/go-gitea/gitea/releases/tag/1.26.3) - 2026-06-18
* BREAKING
* fix(actions)!: require merged PR to bypass fork PR approval gate (#38010) (#38041)
* SECURITY
* fix(hostmatcher): patch incorrect private list (#38170) (#38173)
* fix: Various security fixes (#38103) (#38151)
* fix: Various sec fixes (#38108) (#38147)
* fix: allow git clone of private repos with anonymous code access (#38074) (#38146)
* fix(auth): ignore stale OIDC external login links to organizations (#37875) (#38141)
* fix(hostmatcher): block reserved IP ranges from external/private filters (#38039) (#38059)
* fix(lfs): require Code-unit access for cross-repo LFS object reuse (#38006) (#38050)
* fix(lfs): reject unknown SSH LFS sub-verbs to prevent auth bypass (#38008) (#38015)
* fix: bound CODEOWNERS regex match time (#38011) (#38025)
* fix: bound debian ParseControlFile to a single control stanza (#38044) (#38055)
* fix(deps): update module golang.org/x/net to v0.55.0 [security] (#37813) (#37829)
* API
* feat(api): add Link header in ListForks (#38052) (#38063)
* BUGFIXES
* fix: Fix the panic when ssh remote lfs endpoint parsing failure (#38026) (#38158)
* fix(api): nil pointer panic when filtering tracked times by a non-existent user (#38112) (#38115)
* fix: keep literal "false" value displayed in workflow_dispatch choice dropdowns (#38080) (#38096)
* fix: parse HEAD ref (#38119)
* fix: git cmd (#38084) (#38087)
* fix(releases): generate notes for initial tag (#37697) (#37986)
* fix(actions): return 404 when job log blob is missing (#38003) (#38004)
* fix(actions): exclude `workflow_call` from workflow trigger detection (#37894) (#37899)
* fix(actions): keep action run title clickable when commit subject is a URL (#37867) (#37898)
* fix(actions): reject workflow_dispatch for workflows without that trigger (#37660) (#37895)
* fix(actions): ack re-sent `UpdateLog` finalize idempotently (#37885) (#37892)
* fix: http content file render (#37850) (#37856)
* fix(issues): clear stale ReviewTypeRequest when submitting pending review (#37809) (#37815)
* fix: Fix issue target branch selection for non-collaborators (#36916) (#38164)
* BUILD
* fix(deps): update `@playwright/test` to 1.60.0 (#38144)
* ci: add `tools/ci-tools.ts` for the PR labeler workflow (#37831)
* fix(build): swagger css import (#37801) (#37803)
## [1.26.2](https://github.com/go-gitea/gitea/releases/tag/1.26.2) - 2026-05-20 ## [1.26.2](https://github.com/go-gitea/gitea/releases/tag/1.26.2) - 2026-05-20
* SECURITY * SECURITY
@@ -1445,7 +1943,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
* Fix mCaptcha bug (#33659) (#33661) * Fix mCaptcha bug (#33659) (#33661)
* Git graph: don't show detached commits (#33645) (#33650) * Git graph: don't show detached commits (#33645) (#33650)
* Use MatchPhraseQuery for bleve code search (#33628) * Use MatchPhraseQuery for bleve code search (#33628)
* Adjust appearence of commit status webhook (#33778) #33789 * Adjust appearance of commit status webhook (#33778) #33789
* Upgrade golang net from 0.35.0 -> 0.36.0 (#33795) #33796 * Upgrade golang net from 0.35.0 -> 0.36.0 (#33795) #33796
## [1.23.4](https://github.com/go-gitea/gitea/releases/tag/v1.23.4) - 2025-02-16 ## [1.23.4](https://github.com/go-gitea/gitea/releases/tag/v1.23.4) - 2025-02-16
@@ -2176,7 +2674,7 @@ been added to each release, please refer to the [blog](https://blog.gitea.com).
* Optimize repo-list layout to enhance visual experience (#31272) (#31276) * Optimize repo-list layout to enhance visual experience (#31272) (#31276)
* fixed the dropdown menu for the top New button to expand to the left (#31273) (#31275) * fixed the dropdown menu for the top New button to expand to the left (#31273) (#31275)
* Fix Activity Page Contributors dropdown (#31264) (#31269) * Fix Activity Page Contributors dropdown (#31264) (#31269)
* fix: allow actions artifacts storage migration to complete succesfully (#31251) (#31257) * fix: allow actions artifacts storage migration to complete successfully (#31251) (#31257)
* Make blockquote attention recognize more syntaxes (#31240) (#31250) * Make blockquote attention recognize more syntaxes (#31240) (#31250)
* Remove .segment from .project-column (#31204) (#31239) * Remove .segment from .project-column (#31204) (#31239)
* Ignore FindRecentlyPushedNewBranches err (#31164) (#31171) * Ignore FindRecentlyPushedNewBranches err (#31164) (#31171)
@@ -2360,7 +2858,7 @@ Key highlights of this release encompass significant changes categorized under `
* Performance optimization for git push and check permissions for push options (#30104) (#30354) * Performance optimization for git push and check permissions for push options (#30104) (#30354)
* BUGFIXES * BUGFIXES
* Fix close file in the Upload func (#30262) (#30269) * Fix close file in the Upload func (#30262) (#30269)
* Fix inline math blocks can't be preceeded/followed by alphanumerical characters (#30175) (#30250) * Fix inline math blocks can't be preceded/followed by alphanumerical characters (#30175) (#30250)
* Fix missing 0 prefix of GPG key id (#30245) (#30247) * Fix missing 0 prefix of GPG key id (#30245) (#30247)
* Include encoding in signature payload (#30174) (#30181) * Include encoding in signature payload (#30174) (#30181)
* Move from `max( id )` to `max( index )` for latest commit statuses (#30076) (#30155) * Move from `max( id )` to `max( index )` for latest commit statuses (#30076) (#30155)
@@ -5652,7 +6150,7 @@ Key highlights of this release encompass significant changes categorized under `
* Fix navbar on project view (#17749) * Fix navbar on project view (#17749)
* More pleasantly handle broken or missing git repositories (#17747) * More pleasantly handle broken or missing git repositories (#17747)
* Use `*PushUpdateOptions` as receiver (#17724) * Use `*PushUpdateOptions` as receiver (#17724)
* Remove unused `user` paramater (#17723) * Remove unused `user` parameter (#17723)
* Better builtin avatar generator (#17707) * Better builtin avatar generator (#17707)
* Cleanup and use global style on popups (#17674) * Cleanup and use global style on popups (#17674)
* Move user/org deletion to services (#17673) * Move user/org deletion to services (#17673)
+68 -405
View File
@@ -1,5 +1,19 @@
# Contribution Guidelines # Contribution Guidelines
This document explains how to contribute changes to the Gitea project. Topic-specific guides live in separate files so the essentials are easier to find.
| Topic | Document |
|:-----------------------|:-----------------------------------------------------------------|
| Setup and requirements | [docs/build-setup.md](docs/build-setup.md) |
| Development workflow | [docs/development.md](docs/development.md) |
| Build from source | [docs/build-source.md](docs/build-source.md) |
| Running the tests | [docs/testing.md](docs/testing.md) |
| Frontend guidelines | [docs/guidelines-frontend.md](docs/guidelines-frontend.md) |
| Backend guidelines | [docs/guidelines-backend.md](docs/guidelines-backend.md) |
| Refactoring | [docs/guidelines-refactoring.md](docs/guidelines-refactoring.md) |
| Community Governance | [docs/community-governance.md](docs/community-governance.md) |
| Release management | [docs/release-management.md](docs/release-management.md) |
<details><summary>Table of Contents</summary> <details><summary>Table of Contents</summary>
- [Contribution Guidelines](#contribution-guidelines) - [Contribution Guidelines](#contribution-guidelines)
@@ -11,10 +25,6 @@
- [Discuss your design before the implementation](#discuss-your-design-before-the-implementation) - [Discuss your design before the implementation](#discuss-your-design-before-the-implementation)
- [Issue locking](#issue-locking) - [Issue locking](#issue-locking)
- [Building Gitea](#building-gitea) - [Building Gitea](#building-gitea)
- [Dependencies](#dependencies)
- [Backend](#backend)
- [Frontend](#frontend)
- [Design guideline](#design-guideline)
- [Styleguide](#styleguide) - [Styleguide](#styleguide)
- [Copyright](#copyright) - [Copyright](#copyright)
- [Testing](#testing) - [Testing](#testing)
@@ -22,51 +32,23 @@
- [Code review](#code-review) - [Code review](#code-review)
- [Pull request format](#pull-request-format) - [Pull request format](#pull-request-format)
- [PR title and summary](#pr-title-and-summary) - [PR title and summary](#pr-title-and-summary)
- [Milestone](#milestone)
- [Labels](#labels)
- [Breaking PRs](#breaking-prs) - [Breaking PRs](#breaking-prs)
- [What is a breaking PR?](#what-is-a-breaking-pr) - [What is a breaking PR?](#what-is-a-breaking-pr)
- [How to handle breaking PRs?](#how-to-handle-breaking-prs) - [How to handle breaking PRs?](#how-to-handle-breaking-prs)
- [Maintaining open PRs](#maintaining-open-prs) - [Maintaining open PRs](#maintaining-open-prs)
- [Getting PRs merged](#getting-prs-merged) - [Reviewing PRs](#reviewing-prs)
- [Final call](#final-call) - [For PR authors](#for-pr-authors)
- [Commit messages](#commit-messages)
- [PR Co-authors](#pr-co-authors)
- [PRs targeting `main`](#prs-targeting-main)
- [Backport PRs](#backport-prs)
- [Documentation](#documentation) - [Documentation](#documentation)
- [API v1](#api-v1)
- [GitHub API compatibility](#github-api-compatibility)
- [Adding/Maintaining API routes](#addingmaintaining-api-routes)
- [When to use what HTTP method](#when-to-use-what-http-method)
- [Requirements for API routes](#requirements-for-api-routes)
- [Backports and Frontports](#backports-and-frontports)
- [What is backported?](#what-is-backported)
- [How to backport?](#how-to-backport)
- [Format of backport PRs](#format-of-backport-prs)
- [Frontports](#frontports)
- [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco) - [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco)
- [Release Cycle](#release-cycle)
- [Maintainers](#maintainers)
- [Technical Oversight Committee (TOC)](#technical-oversight-committee-toc)
- [TOC election process](#toc-election-process)
- [Current TOC members](#current-toc-members)
- [Previous TOC/owners members](#previous-tocowners-members)
- [Governance Compensation](#governance-compensation)
- [TOC \& Working groups](#toc--working-groups)
- [Roadmap](#roadmap)
- [Versions](#versions)
- [Releasing Gitea](#releasing-gitea)
</details> </details>
## Introduction ## Introduction
This document explains how to contribute changes to the Gitea project. \
It assumes you have followed the [installation instructions](https://docs.gitea.com/category/installation). \ It assumes you have followed the [installation instructions](https://docs.gitea.com/category/installation). \
Sensitive security-related issues should be reported to [security@gitea.io](mailto:security@gitea.io). Sensitive security-related issues should be reported to [security@gitea.io](mailto:security@gitea.io).
For configuring IDEs for Gitea development, see the [contributed IDE configurations](contrib/ide/). For configuring IDEs for Gitea development, see the [IDE setup notes](docs/development.md#ide-configuration) and the [contributed configurations](contrib/development/).
## AI Contribution Policy ## AI Contribution Policy
@@ -129,35 +111,8 @@ If further discussion is needed, we encourage you to open a new issue instead an
## Building Gitea ## Building Gitea
See the [development setup instructions](https://docs.gitea.com/development/hacking-on-gitea). See [docs/setup.md](docs/setup.md) for prerequisites and [docs/development.md](docs/development.md)
for building Gitea and the development workflow.
## Dependencies
### Backend
Go dependencies are managed using [Go Modules](https://go.dev/cmd/go/#hdr-Module_maintenance). \
You can find more details in the [go mod documentation](https://go.dev/ref/mod) and the [Go Modules Wiki](https://github.com/golang/go/wiki/Modules).
Pull requests should only modify `go.mod` and `go.sum` where it is related to your change, be it a bugfix or a new feature. \
Apart from that, these files should only be modified by Pull Requests whose only purpose is to update dependencies.
The `go.mod`, `go.sum` update needs to be justified as part of the PR description,
and must be verified by the reviewers and/or merger to always reference
an existing upstream commit.
### Frontend
For the frontend, we use [npm](https://www.npmjs.com/).
The same restrictions apply for frontend dependencies as for backend dependencies, with the exceptions that the files for it are `package.json` and `package-lock.json`, and that new versions must always reference an existing version.
## Design guideline
Depending on your change, please read the
- [backend development guideline](https://docs.gitea.com/contributing/guidelines-backend)
- [frontend development guideline](https://docs.gitea.com/contributing/guidelines-frontend)
- [refactoring guideline](https://docs.gitea.com/contributing/guidelines-refactoring)
## Styleguide ## Styleguide
@@ -176,33 +131,7 @@ Afterwards, copyright should only be modified when the copyright author changes.
## Testing ## Testing
Before submitting a pull request, run all tests to make sure your changes don't cause a regression elsewhere. Before submitting a pull request, run the linters (`make lint`, or the scoped `make lint-backend` / `make lint-frontend`) and the tests to make sure your changes don't cause a regression elsewhere. See [docs/testing.md](docs/testing.md) for how to run the unit, integration, end-to-end, and migration tests.
Here's how to run the test suite:
- code lint
| | |
| :-------------------- | :--------------------------------------------------------------------------- |
|``make lint`` | lint everything (not needed if you only change the front- **or** backend) |
|``make lint-frontend`` | lint frontend files |
|``make lint-backend`` | lint backend files |
- run tests (we suggest running them on Linux)
| Command | Action | |
| :------------------------------------------ | :------------------------------------------------------- | ------------------------------------------- |
|``make test[\#SpecificTestName]`` | run unit test(s) | |
|``make test-sqlite[\#SpecificTestName]`` | run [integration](tests/integration) test(s) for SQLite | [More details](tests/integration/README.md) |
|``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | |
- E2E test environment variables
| Variable | Description |
| :-------------------------------- | :---------------------------------------------------------- |
| ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output |
| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright, for example ``--ui`` |
| ``GITEA_TEST_E2E_TIMEOUT_FACTOR`` | Timeout multiplier (default: 3 on CI, 1 locally) |
## Translation ## Translation
@@ -216,6 +145,8 @@ The tool `go run build/backport-locale.go` can be used to backport locales from
## Code review ## Code review
How labels, milestones, and the merge queue work is documented in [docs/community-governance.md](docs/community-governance.md).
### Pull request format ### Pull request format
Please try to make your pull request easy to review for us. \ Please try to make your pull request easy to review for us. \
@@ -238,6 +169,38 @@ In the PR title, describe the problem you are fixing, not how you are fixing it.
Use the first comment as a summary of your PR. \ Use the first comment as a summary of your PR. \
In the PR summary, you can describe exactly how you are fixing this problem. In the PR summary, you can describe exactly how you are fixing this problem.
PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message:
```text
type(scope)!: subject
```
The scope in parentheses is optional. A `!` immediately before the colon marks a [breaking change](https://www.conventionalcommits.org/en/v1.0.0/#summary): either `type!:` or `type(scope)!:` (not `type!(scope):`).
Use one of these types:
- `build`: Changes affecting the build system, packaging, or external dependencies
- `ci`: Changes to CI/CD configuration files and scripts
- `chore`: Maintenance changes that do not affect production code or should not appear in the changelog
- `docs`: Documentation-only changes
- `feat`: A larger user-facing feature, improvement, or new functionality
- `enhance`: Small or trivial user-facing improvements or UX polish (for example wording changes, color adjustments, spacing or padding tweaks, placeholders, small UI behavior improvements)
- `fix`: A bug fix, UX correction, or security-related dependency update
- `perf`: Performance improvements (speed, memory, scalability)
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `revert`: Reverts a previous change
- `style`: Formatting or style-only changes that do not affect code behavior (for example lint-driven edits)
- `test`: Adding or correcting tests
Examples:
```text
fix(web): prevent avatar upload crash on empty file
feat(api): add pagination to repo hooks list
enhance(repo): improve diff toolbar spacing
ci(workflows): lint PR titles in CI
```
Keep this summary up-to-date as the PR evolves. \ Keep this summary up-to-date as the PR evolves. \
If your PR changes the UI, you must add **after** screenshots in the PR summary. \ If your PR changes the UI, you must add **after** screenshots in the PR summary. \
If you are not implementing a new feature, you should also post **before** screenshots for comparison. If you are not implementing a new feature, you should also post **before** screenshots for comparison.
@@ -250,6 +213,10 @@ Another requirement for merging PRs is that the PR is labeled correctly.\
However, this is not your job as a contributor, but the job of the person merging your PR.\ However, this is not your job as a contributor, but the job of the person merging your PR.\
If you think that your PR was labeled incorrectly, or notice that it was merged without labels, please let us know. If you think that your PR was labeled incorrectly, or notice that it was merged without labels, please let us know.
For pull requests that use a valid Conventional Commits title, CI automatically applies a matching `type/…` label when the title prefix is `feat`, `enhance`, `fix`, `docs`, or `test` (for example `enhance(web): …` receives `type/enhancement`).\
That label is kept in sync with the PR title when the title is edited.\
Other title prefixes do not get an automatic `type/…` label; the merger still assigns the correct labels (including `type/…` when needed) for changelog and backport decisions.
If your PR closes some issues, you must note that in a way that both GitHub and Gitea understand, i.e. by appending a paragraph like If your PR closes some issues, you must note that in a way that both GitHub and Gitea understand, i.e. by appending a paragraph like
```text ```text
@@ -260,29 +227,6 @@ Fixes/Closes/Resolves #<ISSUE_NR_Y>.
to your summary. \ to your summary. \
Each issue that will be closed must stand on a separate line. Each issue that will be closed must stand on a separate line.
### Milestone
A PR should only be assigned to a milestone if it will likely be merged into the given version. \
As a rule of thumb, assume that a PR will stay open for an additional month for every 100 added lines. \
PRs without a milestone may not be merged.
### Labels
Almost all labels used inside Gitea can be classified as one of the following:
- `modifies/…`: Determines which parts of the codebase are affected. These labels will be set through the CI.
- `topic/…`: Determines the conceptual component of Gitea that is affected, i.e. issues, projects, or authentication. At best, PRs should only target one component but there might be overlap. Must be set manually.
- `type/…`: Determines the type of an issue or PR (feature, refactoring, docs, bug, …). If GitHub supported scoped labels, these labels would be exclusive, so you should set **exactly** one, not more or less (every PR should fall into one of the provided categories, and only one).
- `issue/…` / `pr/…`: Labels that are specific to issues or PRs respectively and that are only necessary in a given context, i.e. `issue/not-a-bug` or `pr/need-2-approvals`
Every PR should be labeled correctly with every label that applies.
There are also some labels that will be managed automatically.\
In particular, these are
- the amount of pending required approvals
- has all `backport`s or needs a manual backport
### Breaking PRs ### Breaking PRs
#### What is a breaking PR? #### What is a breaking PR?
@@ -311,165 +255,29 @@ Breaking PRs will not be merged as long as not both of these requirements are me
### Maintaining open PRs ### Maintaining open PRs
The moment you create a non-draft PR or the moment you convert a draft PR to a non-draft PR is the moment code review starts for it. \ Code review starts when you open a non-draft PR or move a draft out of draft state. After that, do not rebase or squash your branch; it makes new changes harder to review.
Once that happens, do not rebase or squash your branch anymore as it makes it difficult to review the new changes. \
Merge the base branch into your branch only when you really need to, i.e. because of conflicting changes in the mean time. \
This reduces unnecessary CI runs. \
Don't worry about merge commits messing up your commit history as every PR will be squash merged. \
This means that all changes are joined into a single new commit whose message is as described below.
### Getting PRs merged Merge the base branch into yours only when you need to, for example because of conflicting changes elsewhere. That limits unnecessary CI runs.
Changes to Gitea must be reviewed before they are accepted — no matter who Every PR is squash-merged, so merge commits on your branch do not matter for final history. The squash produces a single commit; mergers follow the [commit message format](docs/community-governance.md#commit-messages) in the governance guide.
makes the change, even if they are an owner or a maintainer. \
The only exception are critical bugs that prevent Gitea from being compiled or started. \
Specifically, we require two approvals from maintainers for every PR. \
Once this criteria has been met, your PR receives the `lgtm/done` label. \
From this point on, your only responsibility is to fix merge conflicts or respond to/implement requests by maintainers. \
It is the responsibility of the maintainers from this point to get your PR merged.
If a PR has the `lgtm/done` label and there are no open discussions or merge conflicts anymore, any maintainer can add the `reviewed/wait-merge` label. \ ### Reviewing PRs
This label means that the PR is part of the merge queue and will be merged as soon as possible. \
The merge queue will be cleared in the order of the list below:
<https://github.com/go-gitea/gitea/pulls?q=is%3Apr+label%3Areviewed%2Fwait-merge+sort%3Acreated-asc+is%3Aopen> Maintainers are encouraged to review pull requests in areas where they have expertise or particular interest.
Gitea uses it's own tool, the <https://github.com/GiteaBot/gitea-backporter> to automate parts of the review process. \ #### For PR authors
This tool does the things listed below automatically:
- create a backport PR if needed once the initial PR was merged - **Response**: When answering reviewer questions, use real-world cases or examples and avoid speculation.
- remove the PR from the merge queue after the PR merged - **Discussion**: A discussion is always welcome and should be used to clarify the changes and the intent of the PR.
- keep the oldest branch in the merge queue up to date with merges - **Help**: If you need help with the PR or comments are unclear, ask for clarification.
### Final call Guidance for reviewers, the merge queue, and the squash commit message format is in [docs/community-governance.md](docs/community-governance.md).
If a PR has been ignored for more than 7 days with no comments or reviews, and the author or any maintainer believes it will not survive a long wait (such as a refactoring PR), they can send "final call" to the TOC by mentioning them in a comment.
After another 7 days, if there is still zero approval, this is considered a polite refusal, and the PR will be closed to avoid wasting further time. Therefore, the "final call" has a cost, and should be used cautiously.
However, if there are no objections from maintainers, the PR can be merged with only one approval from the TOC (not the author).
### Commit messages
Mergers are able and required to rewrite the PR title and summary (the first comment of a PR) so that it can produce an easily understandable commit message if necessary. \
The final commit message should no longer contain any uncertainty such as `hopefully, <x> won't happen anymore`. Replace uncertainty with certainty.
#### PR Co-authors
A person counts as a PR co-author the moment they (co-)authored a commit that is not simply a `Merge base branch into branch` commit. \
Mergers are required to remove such "false-positive" co-authors when writing the commit message. \
The true co-authors must remain in the commit message.
#### PRs targeting `main`
The commit message of PRs targeting `main` is always
```bash
$PR_TITLE ($PR_INDEX)
$REWRITTEN_PR_SUMMARY
```
#### Backport PRs
The commit message of backport PRs is always
```bash
$PR_TITLE ($INITIAL_PR_INDEX) ($BACKPORT_PR_INDEX)
$REWRITTEN_PR_SUMMARY
```
## Documentation ## Documentation
If you add a new feature or change an existing aspect of Gitea, the documentation for that feature must be created or updated in another PR at [https://gitea.com/gitea/docs](https://gitea.com/gitea/docs). If you add a new feature or change an existing aspect of Gitea, the documentation for that feature must be created or updated in another PR at [https://gitea.com/gitea/docs](https://gitea.com/gitea/docs).
**The docs directory on main repository will be removed at some time. We will have a yaml file to store configuration file's meta data. After that completed, configuration documentation should be in the main repository.** **The docs directory on main repository will be removed at some time. We will have a yaml file to store configuration file's meta data. After that completed, configuration documentation should be in the main repository.**
## API v1
The API is documented by [swagger](https://gitea.com/api/swagger) and is based on [the GitHub API](https://docs.github.com/en/rest).
### GitHub API compatibility
Gitea's API should use the same endpoints and fields as the GitHub API as far as possible, unless there are good reasons to deviate. \
If Gitea provides functionality that GitHub does not, a new endpoint can be created. \
If information is provided by Gitea that is not provided by the GitHub API, a new field can be used that doesn't collide with any GitHub fields. \
Updating an existing API should not remove existing fields unless there is a really good reason to do so. \
The same applies to status responses. If you notice a problem, feel free to leave a comment in the code for future refactoring to API v2 (which is currently not planned).
### Adding/Maintaining API routes
All expected results (errors, success, fail messages) must be documented ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L319-L327)). \
All JSON input types must be defined as a struct in [modules/structs/](modules/structs/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L76-L91)) \
and referenced in [routers/api/v1/swagger/options.go](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/options.go). \
They can then be used like [this example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L318). \
All JSON responses must be defined as a struct in [modules/structs/](modules/structs/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L36-L68)) \
and referenced in its category in [routers/api/v1/swagger/](routers/api/v1/swagger/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/issue.go#L11-L16)) \
They can be used like [this example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L277-L279).
### When to use what HTTP method
In general, HTTP methods are chosen as follows:
- **GET** endpoints return the requested object(s) and status **OK (200)**
- **DELETE** endpoints return the status **No Content (204)** and no content either
- **POST** endpoints are used to **create** new objects (e.g. a User) and return the status **Created (201)** and the created object
- **PUT** endpoints are used to **add/assign** existing Objects (e.g. a user to a team) and return the status **No Content (204)** and no content either
- **PATCH** endpoints are used to **edit/change** an existing object and return the changed object and the status **OK (200)**
### Requirements for API routes
All parameters of endpoints changing/editing an object must be optional (except the ones to identify the object, which are required).
Endpoints returning lists must
- support pagination (`page` & `limit` options in query)
- set `X-Total-Count` header via **SetTotalCountHeader** ([example](https://github.com/go-gitea/gitea/blob/7aae98cc5d4113f1e9918b7ee7dd09f67c189e3e/routers/api/v1/repo/issue.go#L444))
## Backports and Frontports
### What is backported?
We backport PRs given the following circumstances:
1. Feature freeze is active, but `<version>-rc0` has not been released yet. Here, we backport as much as possible. <!-- TODO: Is that our definition with the new backport bot? -->
2. `rc0` has been released. Here, we only backport bug- and security-fixes, and small enhancements. Large PRs such as refactors are not backported anymore. <!-- TODO: Is that our definition with the new backport bot? -->
3. We never backport new features.
4. We never backport breaking changes except when
1. The breaking change has no effect on the vast majority of users
2. The component triggering the breaking change is marked as experimental
### How to backport?
In the past, it was necessary to manually backport your PRs. \
Now, that's not a requirement anymore as our [backport bot](https://github.com/GiteaBot) tries to create backports automatically once the PR is merged when the PR
- does not have the label `backport/manual`
- has the label `backport/<version>`
The `backport/manual` label signifies either that you want to backport the change yourself, or that there were conflicts when backporting, thus you **must** do it yourself.
### Format of backport PRs
The title of backport PRs should be
```
<original PR title> (#<original pr number>)
```
The first two lines of the summary of the backporting PR should be
```
Backport #<original pr number>
```
with the rest of the summary and labels matching the original PR.
### Frontports
Frontports behave exactly as described above for backports.
## Developer Certificate of Origin (DCO) ## Developer Certificate of Origin (DCO)
We consider the act of contributing to the code by submitting a Pull Request as the "Sign off" or agreement to the certifications and terms of the [DCO](DCO) and [MIT license](LICENSE). \ We consider the act of contributing to the code by submitting a Pull Request as the "Sign off" or agreement to the certifications and terms of the [DCO](DCO) and [MIT license](LICENSE). \
@@ -483,148 +291,3 @@ Signed-off-by: Joe Smith <joe.smith@email.com>
If you set the `user.name` and `user.email` Git config options, you can add the line to the end of your commits automatically with `git commit -s`. If you set the `user.name` and `user.email` Git config options, you can add the line to the end of your commits automatically with `git commit -s`.
We assume in good faith that the information you provide is legally binding. We assume in good faith that the information you provide is legally binding.
## Release Cycle
We adopted a release schedule to streamline the process of working on, finishing, and issuing releases. \
The overall goal is to make a major release every three or four months, which breaks down into two or three months of general development followed by one month of testing and polishing known as the release freeze. \
All the feature pull requests should be
merged before feature freeze. All feature pull requests haven't been merged before this feature freeze will be moved to next milestone, please notice our feature freeze announcement on discord. And, during the frozen period, a corresponding
release branch is open for fixes backported from main branch. Release candidates
are made during this period for user testing to
obtain a final version that is maintained in this branch.
During a development cycle, we may also publish any necessary minor releases
for the previous version. For example, if the latest, published release is
v1.2, then minor changes for the previous release—e.g., v1.1.0 -> v1.1.1—are
still possible.
## Maintainers
To make sure every PR is checked, we have [maintainers](MAINTAINERS). \
Every PR **must** be reviewed by at least two maintainers (or owners) before it can get merged. \
For refactoring PRs after a week and documentation only PRs, the approval of only one maintainer is enough. \
A maintainer should be a contributor of Gitea and contributed at least
4 accepted PRs. A contributor should apply as a maintainer in the
[Discord](https://discord.gg/Gitea) `#develop` channel. The team maintainers may invite the contributor. A maintainer
should spend some time on code reviews. If a maintainer has no
time to do that, they should apply to leave the maintainers team
and we will give them the honor of being a member of the [advisors
team](https://github.com/orgs/go-gitea/teams/advisors). Of course, if
an advisor has time to code review, we will gladly welcome them back
to the maintainers team. If a maintainer is inactive for more than 3
months and forgets to leave the maintainers team, the owners may move
him or her from the maintainers team to the advisors team.
For security reasons, Maintainers should use 2FA for their accounts and
if possible provide GPG signed commits.
https://help.github.com/articles/securing-your-account-with-two-factor-authentication-2fa/
https://help.github.com/articles/signing-commits-with-gpg/
Furthermore, any account with write access (like bots and TOC members) **must** use 2FA.
https://help.github.com/articles/securing-your-account-with-two-factor-authentication-2fa/
## Technical Oversight Committee (TOC)
At the start of 2023, the `Owners` team was dissolved. Instead, the governance charter proposed a technical oversight committee (TOC) which expands the ownership team of the Gitea project from three elected positions to six positions. Three positions are elected as it has been over the past years, and the other three consist of appointed members from the Gitea company.
https://blog.gitea.com/quarterly-23q1/
### TOC election process
Any maintainer is eligible to be part of the community TOC if they are not associated with the Gitea company.
A maintainer can either nominate themselves, or can be nominated by other maintainers to be a candidate for the TOC election.
If you are nominated by someone else, you must first accept your nomination before the vote starts to be a candidate.
The TOC is elected for one year, the TOC election happens yearly.
After the announcement of the results of the TOC election, elected members have two weeks time to confirm or refuse the seat.
If an elected member does not answer within this timeframe, they are automatically assumed to refuse the seat.
Refusals result in the person with the next highest vote getting the same choice.
As long as seats are empty in the TOC, members of the previous TOC can fill them until an elected member accepts the seat.
If an elected member that accepts the seat does not have 2FA configured yet, they will be temporarily counted as `answer pending` until they manage to configure 2FA, thus leaving their seat empty for this duration.
### Current TOC members
- 2024-01-01 ~ 2024-12-31
- Company
- [Jason Song](https://gitea.com/wolfogre) <i@wolfogre.com>
- [Lunny Xiao](https://gitea.com/lunny) <xiaolunwen@gmail.com>
- [Matti Ranta](https://gitea.com/techknowlogick) <techknowlogick@gitea.com>
- Community
- [6543](https://gitea.com/6543) <6543@obermui.de>
- [delvh](https://gitea.com/delvh) <dev.lh@web.de>
- [John Olheiser](https://gitea.com/jolheiser) <john.olheiser@gmail.com>
### Previous TOC/owners members
Here's the history of the owners and the time they served:
- [Lunny Xiao](https://gitea.com/lunny) - 2016, 2017, [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023
- [Kim Carlbäcker](https://github.com/bkcsoft) - 2016, 2017
- [Thomas Boerger](https://gitea.com/tboerger) - 2016, 2017
- [Lauris Bukšis-Haberkorns](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801)
- [Matti Ranta](https://gitea.com/techknowlogick) - [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023
- [Andrew Thornton](https://gitea.com/zeripath) - [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023
- [6543](https://gitea.com/6543) - 2023
- [John Olheiser](https://gitea.com/jolheiser) - 2023
- [Jason Song](https://gitea.com/wolfogre) - 2023
## Governance Compensation
Each member of the community elected TOC will be granted $500 each month as compensation for their work.
Furthermore, any community release manager for a specific release or LTS will be compensated $500 for the delivery of said release.
These funds will come from community sources like the OpenCollective rather than directly from the company.
Only non-company members are eligible for this compensation, and if a member of the community TOC takes the responsibility of release manager, they would only be compensated for their TOC duties.
Gitea Ltd employees are not eligible to receive any funds from the OpenCollective unless it is reimbursement for a purchase made for the Gitea project itself.
## TOC & Working groups
With Gitea covering many projects outside of the main repository, several groups will be created to help focus on specific areas instead of requiring maintainers to be a jack-of-all-trades. Maintainers are of course more than welcome to be part of multiple groups should they wish to contribute in multiple places.
The currently proposed groups are:
- **Core Group**: maintain the primary Gitea repository
- **Integration Group**: maintain the Gitea ecosystem's related tools, including go-sdk/tea/changelog/bots etc.
- **Documentation Group**: maintain related documents and repositories
- **Translation Group**: coordinate with translators and maintain translations
- **Security Group**: managed by TOC directly, members are decided by TOC, maintains security patches/responsible for security items
## Roadmap
Each year a roadmap will be discussed with the entire Gitea maintainers team, and feedback will be solicited from various stakeholders.
TOC members need to review the roadmap every year and work together on the direction of the project.
When a vote is required for a proposal or other change, the vote of community elected TOC members count slightly more than the vote of company elected TOC members. With this approach, we both avoid ties and ensure that changes align with the mission statement and community opinion.
You can visit our roadmap on the wiki.
## Versions
Gitea has the `main` branch as a tip branch and has version branches
such as `release/v1.19`. `release/v1.19` is a release branch and we will
tag `v1.19.0` for binary download. If `v1.19.0` has bugs, we will accept
pull requests on the `release/v1.19` branch and publish a `v1.19.1` tag,
after bringing the bug fix also to the main branch.
Since the `main` branch is a tip version, if you wish to use Gitea
in production, please download the latest release tag version. All the
branches will be protected via GitHub, all the PRs to every branch must
be reviewed by two maintainers and must pass the automatic tests.
## Releasing Gitea
- Let $vmaj, $vmin and $vpat be Major, Minor and Patch version numbers, $vpat should be rc1, rc2, 0, 1, ...... $vmaj.$vmin will be kept the same as milestones on github or gitea in future.
- Before releasing, confirm all the version's milestone issues or PRs has been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours.
- If this is a big version first you have to create PR for changelog on branch `main` with PRs with label `changelog` and after it has been merged do following steps:
- Create `-dev` tag as `git tag -s -F release.notes v$vmaj.$vmin.0-dev` and push the tag as `git push origin v$vmaj.$vmin.0-dev`.
- When CI has finished building tag then you have to create a new branch named `release/v$vmaj.$vmin`
- If it is bugfix version create PR for changelog on branch `release/v$vmaj.$vmin` and wait till it is reviewed and merged.
- Add a tag as `git tag -s -F release.notes v$vmaj.$vmin.$`, release.notes file could be a temporary file to only include the changelog this version which you added to `CHANGELOG.md`.
- And then push the tag as `git push origin v$vmaj.$vmin.$`. Drone CI will automatically create a release and upload all the compiled binary. (But currently it doesn't add the release notes automatically. Maybe we should fix that.)
- If needed send a frontport PR for the changelog to branch `main` and update the version in `docs/config.yaml` to refer to the new version.
- Send PR to [blog repository](https://gitea.com/gitea/blog) announcing the release.
- Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed:
- bump the version of https://dl.gitea.com/gitea/version.json
- merge the blog post PR
- announce the release in discord `#announcements`
+8 -8
View File
@@ -1,18 +1,18 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem # Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.24 AS frontend-build
RUN apk --no-cache add build-base git nodejs pnpm RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src WORKDIR /src
COPY package.json pnpm-lock.yaml .npmrc ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
COPY --exclude=.git/ . . COPY --exclude=.git/ . .
RUN make frontend RUN make frontend
# Build backend for each target platform # Build backend for each target platform
FROM docker.io/library/golang:1.26-alpine3.23 AS build-env FROM docker.io/library/golang:1.26-alpine3.24 AS build-env
ARG GITEA_VERSION ARG GITEA_VERSION
ARG TAGS="sqlite sqlite_unlock_notify" ARG TAGS=""
ENV TAGS="bindata timetzdata $TAGS" ENV TAGS="bindata timetzdata $TAGS"
ARG CGO_EXTRA_CFLAGS ARG CGO_EXTRA_CFLAGS
@@ -21,7 +21,7 @@ RUN apk --no-cache add \
build-base \ build-base \
git git
WORKDIR ${GOPATH}/src/code.gitea.io/gitea WORKDIR ${GOPATH}/src/gitea.dev
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Use COPY instead of bind mount as read-only one breaks makefile state tracking and read-write one needs binary to be moved as it's discarded. # Use COPY instead of bind mount as read-only one breaks makefile state tracking and read-write one needs binary to be moved as it's discarded.
@@ -42,9 +42,9 @@ RUN chmod 755 /tmp/local/usr/bin/entrypoint \
/tmp/local/etc/s6/gitea/* \ /tmp/local/etc/s6/gitea/* \
/tmp/local/etc/s6/openssh/* \ /tmp/local/etc/s6/openssh/* \
/tmp/local/etc/s6/.s6-svscan/* \ /tmp/local/etc/s6/.s6-svscan/* \
/go/src/code.gitea.io/gitea/gitea /go/src/gitea.dev/gitea
FROM docker.io/library/alpine:3.23 AS gitea FROM docker.io/library/alpine:3.24 AS gitea
EXPOSE 22 3000 EXPOSE 22 3000
@@ -74,7 +74,7 @@ RUN addgroup \
echo "git:*" | chpasswd -e echo "git:*" | chpasswd -e
COPY --from=build-env /tmp/local / COPY --from=build-env /tmp/local /
COPY --from=build-env /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea COPY --from=build-env /go/src/gitea.dev/gitea /app/gitea/gitea
ENV USER=git ENV USER=git
ENV GITEA_CUSTOM=/data/gitea ENV GITEA_CUSTOM=/data/gitea
+8 -8
View File
@@ -1,18 +1,18 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem # Build frontend on the native platform to avoid QEMU-related issues with nodejs ecosystem
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.23 AS frontend-build FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine3.24 AS frontend-build
RUN apk --no-cache add build-base git nodejs pnpm RUN apk --no-cache add build-base git nodejs pnpm
WORKDIR /src WORKDIR /src
COPY package.json pnpm-lock.yaml .npmrc ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile RUN --mount=type=cache,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
COPY --exclude=.git/ . . COPY --exclude=.git/ . .
RUN make frontend RUN make frontend
# Build backend for each target platform # Build backend for each target platform
FROM docker.io/library/golang:1.26-alpine3.23 AS build-env FROM docker.io/library/golang:1.26-alpine3.24 AS build-env
ARG GITEA_VERSION ARG GITEA_VERSION
ARG TAGS="sqlite sqlite_unlock_notify" ARG TAGS=""
ENV TAGS="bindata timetzdata $TAGS" ENV TAGS="bindata timetzdata $TAGS"
ARG CGO_EXTRA_CFLAGS ARG CGO_EXTRA_CFLAGS
@@ -21,7 +21,7 @@ RUN apk --no-cache add \
build-base \ build-base \
git git
WORKDIR ${GOPATH}/src/code.gitea.io/gitea WORKDIR ${GOPATH}/src/gitea.dev
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# See the comments in Dockerfile # See the comments in Dockerfile
@@ -37,9 +37,9 @@ COPY docker/rootless /tmp/local
# Set permissions for builds that made under windows which strips the executable bit from file # Set permissions for builds that made under windows which strips the executable bit from file
RUN chmod 755 /tmp/local/usr/local/bin/* \ RUN chmod 755 /tmp/local/usr/local/bin/* \
/go/src/code.gitea.io/gitea/gitea /go/src/gitea.dev/gitea
FROM docker.io/library/alpine:3.23 AS gitea-rootless FROM docker.io/library/alpine:3.24 AS gitea-rootless
EXPOSE 2222 3000 EXPOSE 2222 3000
@@ -68,7 +68,7 @@ RUN mkdir -p /var/lib/gitea /etc/gitea
RUN chown git:git /var/lib/gitea /etc/gitea RUN chown git:git /var/lib/gitea /etc/gitea
COPY --from=build-env /tmp/local / COPY --from=build-env /tmp/local /
COPY --from=build-env --chown=root:root /go/src/code.gitea.io/gitea/gitea /app/gitea/gitea COPY --from=build-env --chown=root:root /go/src/gitea.dev/gitea /app/gitea/gitea
# git:git # git:git
USER 1000:1000 USER 1000:1000
+122 -278
View File
@@ -7,33 +7,44 @@ export GOEXPERIMENT ?= jsonv2
GO ?= go GO ?= go
SHASUM ?= shasum -a 256 SHASUM ?= shasum -a 256
HAS_GO := $(shell hash $(GO) > /dev/null 2>&1 && echo yes)
COMMA := , COMMA := ,
XGO_VERSION := go-1.25.x XGO_VERSION := go-1.26.x
AIR_PACKAGE ?= github.com/air-verse/air@v1 AIR_PACKAGE ?= github.com/air-verse/air@v1.65.3 # renovate: datasource=go
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3 EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3.7.0 # renovate: datasource=go
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 # renovate: datasource=go
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.1 # renovate: datasource=go
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.1 XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.4.0 # renovate: datasource=go
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # renovate: datasource=go
ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.11 SHELLCHECK_IMAGE ?= docker.io/koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d # renovate: datasource=docker
DOCKER_IMAGE ?= gitea/gitea CONTAINER_RUNTIME ?= $(shell hash docker >/dev/null 2>&1 && echo docker || echo podman)
DOCKER_TAG ?= latest
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
HAS_GO := $(shell hash $(GO) > /dev/null 2>&1 && echo yes)
ifeq ($(HAS_GO), yes) ifeq ($(HAS_GO), yes)
CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766 CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766
CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS) CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS)
endif endif
MAKE_EVIDENCE_DIR := .make_evidence
# Use sqlite as default database if running tests, only do so for local tests, not in CI.
# CI should explicitly set the database to avoid unexpected results.
ifneq ($(findstring test-,$(MAKECMDGOALS)),)
ifeq ($(CI),)
GITEA_TEST_DATABASE ?= sqlite
endif
endif
TAGS ?=
TAGS_EVIDENCE := $(MAKE_EVIDENCE_DIR)/tags
CGO_ENABLED ?= 0 CGO_ENABLED ?= 0
ifneq (,$(findstring sqlite,$(TAGS))$(findstring pam,$(TAGS))) ifneq (,$(findstring sqlite_mattn,$(TAGS))$(findstring pam,$(TAGS)))
CGO_ENABLED = 1 CGO_ENABLED = 1
endif endif
@@ -50,15 +61,16 @@ else ifeq ($(patsubst Windows%,Windows,$(OS)),Windows)
IS_WINDOWS := yes IS_WINDOWS := yes
endif endif
endif endif
# GOFLAGS and EXTRA_GOFLAGS are for the 'go build' command only
ifeq ($(IS_WINDOWS),yes) ifeq ($(IS_WINDOWS),yes)
GOFLAGS := -v -buildmode=exe GOFLAGS := -v -buildmode=exe
EXECUTABLE ?= gitea.exe EXECUTABLE ?= gitea.exe
EXECUTABLE_E2E ?= gitea-e2e.exe
else else
GOFLAGS := -v GOFLAGS := -v
EXECUTABLE ?= gitea EXECUTABLE ?= gitea
EXECUTABLE_E2E ?= gitea-e2e
endif endif
EXTRA_GOFLAGS ?=
ifeq ($(shell sed --version 2>/dev/null | grep -q GNU && echo gnu),gnu) ifeq ($(shell sed --version 2>/dev/null | grep -q GNU && echo gnu),gnu)
SED_INPLACE := sed -i SED_INPLACE := sed -i
@@ -66,15 +78,8 @@ else
SED_INPLACE := sed -i '' SED_INPLACE := sed -i ''
endif endif
EXTRA_GOFLAGS ?= # GOTEST_FLAGS is for unit test and integration test
GOTEST_FLAGS ?= -timeout 40m
MAKE_EVIDENCE_DIR := .make_evidence
GOTESTFLAGS ?=
ifeq ($(RACE_ENABLED),true)
GOFLAGS += -race
GOTESTFLAGS += -race
endif
STORED_VERSION_FILE := VERSION STORED_VERSION_FILE := VERSION
@@ -108,8 +113,8 @@ LDFLAGS := $(LDFLAGS) -X "main.Version=$(GITEA_VERSION)" -X "main.Tags=$(TAGS)"
LINUX_ARCHS ?= linux/amd64,linux/386,linux/arm-5,linux/arm-6,linux/arm64,linux/riscv64 LINUX_ARCHS ?= linux/amd64,linux/386,linux/arm-5,linux/arm-6,linux/arm64,linux/riscv64
GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) code.gitea.io/gitea/tests/integration/migration-test code.gitea.io/gitea/tests code.gitea.io/gitea/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/)) GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list gitea.dev/models/migrations/...) gitea.dev/tests/integration/migration-test gitea.dev/tests gitea.dev/tests/integration,$(shell $(GO) list ./... | grep -v /vendor/))
MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...) MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list gitea.dev/models/migrations/...)
FRONTEND_SOURCES := $(shell find web_src/js web_src/css -type f) FRONTEND_SOURCES := $(shell find web_src/js web_src/css -type f)
FRONTEND_CONFIGS := vite.config.ts tailwind.config.ts FRONTEND_CONFIGS := vite.config.ts tailwind.config.ts
@@ -122,17 +127,12 @@ BINDATA_DEST_WILDCARD := modules/migration/bindata.* modules/public/bindata.* mo
GENERATED_GO_DEST := modules/charset/invisible_gen.go modules/charset/ambiguous_gen.go GENERATED_GO_DEST := modules/charset/invisible_gen.go modules/charset/ambiguous_gen.go
SVG_DEST_DIR := public/assets/img/svg SVG_DEST_DIR := public/assets/img/svg
SVG_DEST_DIRS := $(SVG_DEST_DIR) options/fileicon
AIR_TMP_DIR := .air AIR_TMP_DIR := .air
GO_LICENSE_FILE := assets/go-licenses.json GO_LICENSE_FILE := assets/go-licenses.json
TAGS ?=
TAGS_SPLIT := $(subst $(COMMA), ,$(TAGS))
TAGS_EVIDENCE := $(MAKE_EVIDENCE_DIR)/tags
TEST_TAGS ?= $(TAGS_SPLIT) sqlite sqlite_unlock_notify
TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(DIST) $(MAKE_EVIDENCE_DIR) $(AIR_TMP_DIR) TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(DIST) $(MAKE_EVIDENCE_DIR) $(AIR_TMP_DIR)
GO_DIRS := build cmd models modules routers services tests tools GO_DIRS := build cmd models modules routers services tests tools
@@ -151,7 +151,8 @@ ESLINT_CONCURRENCY ?= 2
SWAGGER_SPEC := templates/swagger/v1_json.tmpl SWAGGER_SPEC := templates/swagger/v1_json.tmpl
SWAGGER_SPEC_INPUT := templates/swagger/v1_input.json SWAGGER_SPEC_INPUT := templates/swagger/v1_input.json
SWAGGER_EXCLUDE := code.gitea.io/sdk SWAGGER_EXCLUDE := gitea.dev/sdk
OPENAPI3_SPEC := templates/swagger/v1_openapi3_json.tmpl
TEST_MYSQL_HOST ?= mysql:3306 TEST_MYSQL_HOST ?= mysql:3306
TEST_MYSQL_DBNAME ?= testgitea TEST_MYSQL_DBNAME ?= testgitea
@@ -164,13 +165,19 @@ TEST_PGSQL_PASSWORD ?= postgres
TEST_PGSQL_SCHEMA ?= gtestschema TEST_PGSQL_SCHEMA ?= gtestschema
TEST_MINIO_ENDPOINT ?= minio:9000 TEST_MINIO_ENDPOINT ?= minio:9000
TEST_MSSQL_HOST ?= mssql:1433 TEST_MSSQL_HOST ?= mssql:1433
TEST_MSSQL_DBNAME ?= gitea TEST_MSSQL_DBNAME ?= testgitea
TEST_MSSQL_USERNAME ?= sa TEST_MSSQL_USERNAME ?= sa
TEST_MSSQL_PASSWORD ?= MwantsaSecurePassword1 TEST_MSSQL_PASSWORD ?= MwantsaSecurePassword1
# Include local Makefile # Include local Makefile
# Makefile.local is listed in .gitignore # Makefile.local is listed in .gitignore
sinclude Makefile.local ifneq ("$(wildcard Makefile.local)","")
include Makefile.local
endif
$(foreach v, $(filter TEST_%, $(.VARIABLES)), $(eval MAKEFILE_VARS+=$v=$($v)))
$(foreach v, $(filter GITEA_TEST_%, $(.VARIABLES)), $(eval MAKEFILE_VARS+=$v=$($v)))
export MAKEFILE_VARS
.PHONY: all .PHONY: all
all: build all: build
@@ -179,15 +186,8 @@ all: build
help: Makefile ## print Makefile help information. help: Makefile ## print Makefile help information.
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[TARGETS] default target: build\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf " \033[36m%-45s\033[0m %s\n", $$1, $$2 }' Makefile #$(MAKEFILE_LIST) @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m[TARGETS] default target: build\033[0m\n\n\033[35mTargets:\033[0m\n"} /^[0-9A-Za-z._-]+:.*?##/ { printf " \033[36m%-45s\033[0m %s\n", $$1, $$2 }' Makefile #$(MAKEFILE_LIST)
@printf " \033[36m%-46s\033[0m %s\n" "test-e2e" "test end to end using playwright" @printf " \033[36m%-46s\033[0m %s\n" "test-e2e" "test end to end using playwright"
@printf " \033[36m%-46s\033[0m %s\n" "test[#TestSpecificName]" "run unit test" @printf " \033[36m%-46s\033[0m %s\n" "test-backend[#TestSpecificName]" "run unit test (sqlite only)"
@printf " \033[36m%-46s\033[0m %s\n" "test-sqlite[#TestSpecificName]" "run integration test for sqlite" @printf " \033[36m%-46s\033[0m %s\n" "test-integration[#TestSpecificName]" "run integration test for GITEA_TEST_DATABASE (sqlite, mysql, pgsql, mssql)"
.PHONY: git-check
git-check:
@if git lfs >/dev/null 2>&1 ; then : ; else \
echo "Gitea requires git with lfs support to run tests." ; \
exit 1; \
fi
.PHONY: clean-all .PHONY: clean-all
clean-all: clean ## delete backend, frontend and integration files clean-all: clean ## delete backend, frontend and integration files
@@ -195,18 +195,12 @@ clean-all: clean ## delete backend, frontend and integration files
.PHONY: clean .PHONY: clean
clean: ## delete backend and integration files clean: ## delete backend and integration files
rm -rf $(EXECUTABLE) $(EXECUTABLE_E2E) $(DIST) $(BINDATA_DEST_WILDCARD) \ rm -f $(EXECUTABLE) test-*.test tests/*.ini
integrations*.test \ rm -rf $(DIST) $(BINDATA_DEST_WILDCARD) man tests/integration/gitea-integration-*
tests/integration/gitea-integration-* \
tests/integration/indexers-* \
tests/sqlite.ini tests/mysql.ini tests/pgsql.ini tests/mssql.ini man/ \
tests/e2e/gitea-e2e-*/ \
tests/e2e/indexers-*/ \
tests/e2e/reports/ tests/e2e/test-artifacts/ tests/e2e/test-snapshots/
.PHONY: fmt .PHONY: fmt
fmt: ## format the Go and template code fmt: ## format the Go and template code
@GOFUMPT_PACKAGE=$(GOFUMPT_PACKAGE) $(GO) run tools/code-batch-process.go gitea-fmt -w '{file-list}' $(GO) run $(GOLANGCI_LINT_PACKAGE) fmt
$(eval TEMPLATES := $(shell find templates -type f -name '*.tmpl')) $(eval TEMPLATES := $(shell find templates -type f -name '*.tmpl'))
@# strip whitespace after '{{' or '(' and before '}}' or ')' unless there is only @# strip whitespace after '{{' or '(' and before '}}' or ')' unless there is only
@# whitespace before it @# whitespace before it
@@ -234,7 +228,7 @@ TAGS_PREREQ := $(TAGS_EVIDENCE)
endif endif
.PHONY: generate-swagger .PHONY: generate-swagger
generate-swagger: $(SWAGGER_SPEC) ## generate the swagger spec from code comments generate-swagger: $(SWAGGER_SPEC) $(OPENAPI3_SPEC) ## generate the swagger spec from code comments
$(SWAGGER_SPEC): $(GO_SOURCES) $(SWAGGER_SPEC_INPUT) $(SWAGGER_SPEC): $(GO_SOURCES) $(SWAGGER_SPEC_INPUT)
$(GO) run $(SWAGGER_PACKAGE) generate spec --exclude "$(SWAGGER_EXCLUDE)" --input "$(SWAGGER_SPEC_INPUT)" --output './$(SWAGGER_SPEC)' $(GO) run $(SWAGGER_PACKAGE) generate spec --exclude "$(SWAGGER_EXCLUDE)" --input "$(SWAGGER_SPEC_INPUT)" --output './$(SWAGGER_SPEC)'
@@ -256,6 +250,21 @@ swagger-validate: ## check if the swagger spec is valid
$(GO) run $(SWAGGER_PACKAGE) validate './$(SWAGGER_SPEC)' $(GO) run $(SWAGGER_PACKAGE) validate './$(SWAGGER_SPEC)'
@$(SED_INPLACE) -E -e 's|"basePath":( *)"/(.*)"|"basePath":\1"\2"|g' './$(SWAGGER_SPEC)' # remove the prefix slash from basePath @$(SED_INPLACE) -E -e 's|"basePath":( *)"/(.*)"|"basePath":\1"\2"|g' './$(SWAGGER_SPEC)' # remove the prefix slash from basePath
.PHONY: generate-openapi3
generate-openapi3: $(OPENAPI3_SPEC) ## generate the OpenAPI 3.0 spec from the Swagger 2.0 spec
$(OPENAPI3_SPEC): $(SWAGGER_SPEC) build/generate-openapi.go $(wildcard build/openapi3gen/*.go)
$(GO) run build/generate-openapi.go
.PHONY: openapi3-check
openapi3-check: generate-openapi3
@diff=$$(git diff --color=always '$(OPENAPI3_SPEC)'); \
if [ -n "$$diff" ]; then \
echo "Please run 'make generate-openapi3' and commit the result:"; \
printf "%s" "$${diff}"; \
exit 1; \
fi
.PHONY: checks .PHONY: checks
checks: checks-frontend checks-backend ## run various consistency checks checks: checks-frontend checks-backend ## run various consistency checks
@@ -263,10 +272,10 @@ checks: checks-frontend checks-backend ## run various consistency checks
checks-frontend: lockfile-check svg-check ## check frontend files checks-frontend: lockfile-check svg-check ## check frontend files
.PHONY: checks-backend .PHONY: checks-backend
checks-backend: tidy-check swagger-check fmt-check swagger-validate security-check ## check backend files checks-backend: tidy-check swagger-check openapi3-check fmt-check swagger-validate security-check ## check backend files
.PHONY: lint .PHONY: lint
lint: lint-frontend lint-backend lint-spell ## lint everything lint: lint-frontend lint-backend lint-templates lint-swagger lint-spell lint-md lint-actions lint-json lint-yaml lint-shell ## lint everything
.PHONY: lint-fix .PHONY: lint-fix
lint-fix: lint-frontend-fix lint-backend-fix lint-spell-fix ## lint everything and fix issues lint-fix: lint-frontend-fix lint-backend-fix lint-spell-fix ## lint everything and fix issues
@@ -278,10 +287,10 @@ lint-frontend: lint-js lint-css ## lint frontend files
lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues
.PHONY: lint-backend .PHONY: lint-backend
lint-backend: lint-go lint-go-gitea-vet lint-editorconfig ## lint backend files lint-backend: lint-go lint-editorconfig ## lint backend files
.PHONY: lint-backend-fix .PHONY: lint-backend-fix
lint-backend-fix: lint-go-fix lint-go-gitea-vet lint-editorconfig ## lint backend files and fix issues lint-backend-fix: lint-go-fix lint-editorconfig ## lint backend files and fix issues
.PHONY: lint-js .PHONY: lint-js
lint-js: node_modules ## lint js and ts files lint-js: node_modules ## lint js and ts files
@@ -323,23 +332,11 @@ lint-spell-fix: ## lint spelling and fix issues
.PHONY: lint-go .PHONY: lint-go
lint-go: ## lint go files lint-go: ## lint go files
$(GO) run $(GOLANGCI_LINT_PACKAGE) run GO=$(GO) GOLANGCI_LINT_PACKAGE=$(GOLANGCI_LINT_PACKAGE) $(GO) run ./tools/lint-go-all.go
.PHONY: lint-go-fix .PHONY: lint-go-fix
lint-go-fix: ## lint go files and fix issues lint-go-fix: ## lint go files and fix issues
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix GO=$(GO) GOLANGCI_LINT_PACKAGE=$(GOLANGCI_LINT_PACKAGE) $(GO) run ./tools/lint-go-all.go --fix
# workaround step for the lint-go-windows CI task because 'go run' can not
# have distinct GOOS/GOARCH for its build and run steps
.PHONY: lint-go-windows
lint-go-windows:
@GOOS= GOARCH= $(GO) install $(GOLANGCI_LINT_PACKAGE)
golangci-lint run
.PHONY: lint-go-gitea-vet
lint-go-gitea-vet: ## lint go files with gitea-vet
@echo "Running gitea-vet..."
@$(GO) vet -vettool="$(shell GOOS= GOARCH= go tool -n gitea-vet)" ./...
.PHONY: lint-editorconfig .PHONY: lint-editorconfig
lint-editorconfig: lint-editorconfig:
@@ -347,8 +344,13 @@ lint-editorconfig:
@$(GO) run $(EDITORCONFIG_CHECKER_PACKAGE) $(EDITORCONFIG_FILES) @$(GO) run $(EDITORCONFIG_CHECKER_PACKAGE) $(EDITORCONFIG_FILES)
.PHONY: lint-actions .PHONY: lint-actions
lint-actions: ## lint action workflow files lint-actions: .venv ## lint action workflow files
$(GO) run $(ACTIONLINT_PACKAGE) @$(GO) run $(ACTIONLINT_PACKAGE)
@uv run --frozen zizmor --quiet --min-confidence=medium .github
.PHONY: lint-shell
lint-shell: ## lint shell scripts
@SHELLCHECK_IMAGE=$(SHELLCHECK_IMAGE) CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) ./tools/lint-shell.sh $$(git ls-files '*.sh')
.PHONY: lint-templates .PHONY: lint-templates
lint-templates: .venv node_modules ## lint template files lint-templates: .venv node_modules ## lint template files
@@ -379,13 +381,10 @@ watch-frontend: node_modules ## start vite dev server for frontend
watch-backend: ## watch backend files and continuously rebuild watch-backend: ## watch backend files and continuously rebuild
GITEA_RUN_MODE=dev $(GO) run $(AIR_PACKAGE) -c .air.toml GITEA_RUN_MODE=dev $(GO) run $(AIR_PACKAGE) -c .air.toml
.PHONY: test
test: test-frontend test-backend ## test everything
.PHONY: test-backend .PHONY: test-backend
test-backend: ## test backend files test-backend: ## test backend files
@echo "Running go test with $(GOTESTFLAGS) -tags '$(TEST_TAGS)'..." @echo "Running go test with $(GOTEST_FLAGS) -tags '$(TAGS)'..."
@$(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' $(GO_TEST_PACKAGES) @$(GO) test $(GOTEST_FLAGS) -tags='$(TAGS)' $(GO_TEST_PACKAGES)
.PHONY: test-frontend .PHONY: test-frontend
test-frontend: node_modules ## test frontend files test-frontend: node_modules ## test frontend files
@@ -403,10 +402,10 @@ test-check:
exit 1; \ exit 1; \
fi fi
.PHONY: test\#% .PHONY: test-backend\#%
test\#%: test-backend\#%:
@echo "Running go test with -tags '$(TEST_TAGS)'..." @echo "Running go test with -tags '$(TAGS)'..."
@$(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -run $(subst .,/,$*) $(GO_TEST_PACKAGES) @$(GO) test $(GOTEST_FLAGS) -tags='$(TAGS)' -run $(subst .,/,$*) $(GO_TEST_PACKAGES)
.PHONY: coverage .PHONY: coverage
coverage: coverage:
@@ -416,8 +415,8 @@ coverage:
.PHONY: unit-test-coverage .PHONY: unit-test-coverage
unit-test-coverage: unit-test-coverage:
@echo "Running unit-test-coverage $(GOTESTFLAGS) -tags '$(TEST_TAGS)'..." @echo "Running unit-test-coverage $(GOTEST_FLAGS) -tags '$(TAGS)'..."
@$(GO) test $(GOTESTFLAGS) -timeout=20m -tags='$(TEST_TAGS)' -cover -coverprofile coverage.out $(GO_TEST_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 @$(GO) test $(GOTEST_FLAGS) -tags='$(TAGS)' -cover -coverprofile coverage.out $(GO_TEST_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: tidy .PHONY: tidy
tidy: ## run go mod tidy tidy: ## run go mod tidy
@@ -444,193 +443,45 @@ go-licenses: $(GO_LICENSE_FILE) ## regenerate go licenses
$(GO_LICENSE_FILE): go.mod go.sum $(GO_LICENSE_FILE): go.mod go.sum
GO=$(GO) $(GO) run build/generate-go-licenses.go $(GO_LICENSE_FILE) GO=$(GO) $(GO) run build/generate-go-licenses.go $(GO_LICENSE_FILE)
generate-ini-sqlite: .PHONY: test-integration
sed -e 's|{{WORK_PATH}}|$(CURDIR)/tests/$(or $(TEST_TYPE),integration)/gitea-$(or $(TEST_TYPE),integration)-sqlite|g' \ test-integration:
-e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \ @# Use a compiled binary: testlogger forwards gitea logs to t.Log, so `go test -v`
tests/sqlite.ini.tmpl > tests/sqlite.ini @# would flood output per passing test. testcache can't help these tests anyway —
@# they mutate the work directory, so cache inputs change between runs.
$(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -c gitea.dev/tests/integration -o ./test-integration-$(GITEA_TEST_DATABASE).test
./tools/test-integration.sh ./test-integration-$(GITEA_TEST_DATABASE).test
.PHONY: test-sqlite .PHONY: test-integration-compile
test-sqlite: integrations.sqlite.test generate-ini-sqlite test-integration-compile:
GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -c -o /dev/null gitea.dev/tests/integration
.PHONY: test-sqlite\#% .PHONY: test-integration\#%
test-sqlite\#%: integrations.sqlite.test generate-ini-sqlite test-integration\#%:
GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.run $(subst .,/,$*) $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -run $(subst .,/,$*) gitea.dev/tests/integration
.PHONY: test-sqlite-migration .PHONY: test-migration
test-sqlite-migration: migrations.sqlite.test migrations.individual.sqlite.test test-migration: migrations.integration.test migrations.individual.test
generate-ini-mysql: .PHONY: migrations.integration.test
sed -e 's|{{TEST_MYSQL_HOST}}|${TEST_MYSQL_HOST}|g' \ migrations.integration.test:
-e 's|{{TEST_MYSQL_DBNAME}}|${TEST_MYSQL_DBNAME}|g' \ $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' gitea.dev/tests/integration/migration-test
-e 's|{{TEST_MYSQL_USERNAME}}|${TEST_MYSQL_USERNAME}|g' \
-e 's|{{TEST_MYSQL_PASSWORD}}|${TEST_MYSQL_PASSWORD}|g' \
-e 's|{{WORK_PATH}}|$(CURDIR)/tests/$(or $(TEST_TYPE),integration)/gitea-$(or $(TEST_TYPE),integration)-mysql|g' \
-e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \
tests/mysql.ini.tmpl > tests/mysql.ini
.PHONY: test-mysql .PHONY: migrations.individual.test
test-mysql: integrations.mysql.test generate-ini-mysql migrations.individual.test:
GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test @# tests of multiple packages use the same database, don't run in parallel
$(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES)
.PHONY: test-mysql\#% .PHONY: migrations.individual.test\#%
test-mysql\#%: integrations.mysql.test generate-ini-mysql migrations.individual.test\#%:
GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test -test.run $(subst .,/,$*) $(GO) test $(GOTEST_FLAGS) -tags '$(TAGS)' gitea.dev/models/migrations/$*
.PHONY: test-mysql-migration
test-mysql-migration: migrations.mysql.test migrations.individual.mysql.test
generate-ini-pgsql:
sed -e 's|{{TEST_PGSQL_HOST}}|${TEST_PGSQL_HOST}|g' \
-e 's|{{TEST_PGSQL_DBNAME}}|${TEST_PGSQL_DBNAME}|g' \
-e 's|{{TEST_PGSQL_USERNAME}}|${TEST_PGSQL_USERNAME}|g' \
-e 's|{{TEST_PGSQL_PASSWORD}}|${TEST_PGSQL_PASSWORD}|g' \
-e 's|{{TEST_PGSQL_SCHEMA}}|${TEST_PGSQL_SCHEMA}|g' \
-e 's|{{TEST_MINIO_ENDPOINT}}|${TEST_MINIO_ENDPOINT}|g' \
-e 's|{{WORK_PATH}}|$(CURDIR)/tests/$(or $(TEST_TYPE),integration)/gitea-$(or $(TEST_TYPE),integration)-pgsql|g' \
-e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \
tests/pgsql.ini.tmpl > tests/pgsql.ini
.PHONY: test-pgsql
test-pgsql: integrations.pgsql.test generate-ini-pgsql
GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test
.PHONY: test-pgsql\#%
test-pgsql\#%: integrations.pgsql.test generate-ini-pgsql
GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.run $(subst .,/,$*)
.PHONY: test-pgsql-migration
test-pgsql-migration: migrations.pgsql.test migrations.individual.pgsql.test
generate-ini-mssql:
sed -e 's|{{TEST_MSSQL_HOST}}|${TEST_MSSQL_HOST}|g' \
-e 's|{{TEST_MSSQL_DBNAME}}|${TEST_MSSQL_DBNAME}|g' \
-e 's|{{TEST_MSSQL_USERNAME}}|${TEST_MSSQL_USERNAME}|g' \
-e 's|{{TEST_MSSQL_PASSWORD}}|${TEST_MSSQL_PASSWORD}|g' \
-e 's|{{WORK_PATH}}|$(CURDIR)/tests/$(or $(TEST_TYPE),integration)/gitea-$(or $(TEST_TYPE),integration)-mssql|g' \
-e 's|{{TEST_LOGGER}}|$(or $(TEST_LOGGER),test$(COMMA)file)|g' \
tests/mssql.ini.tmpl > tests/mssql.ini
.PHONY: test-mssql
test-mssql: integrations.mssql.test generate-ini-mssql
GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test
.PHONY: test-mssql\#%
test-mssql\#%: integrations.mssql.test generate-ini-mssql
GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test -test.run $(subst .,/,$*)
.PHONY: test-mssql-migration
test-mssql-migration: migrations.mssql.test migrations.individual.mssql.test
.PHONY: playwright .PHONY: playwright
playwright: deps-frontend playwright: deps-frontend
@# on GitHub Actions VMs, playwright's system deps are pre-installed @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) ./tools/test-e2e.sh install
@pnpm exec playwright install $(if $(GITHUB_ACTIONS),,--with-deps) chromium firefox $(PLAYWRIGHT_FLAGS)
.PHONY: test-e2e .PHONY: test-e2e
test-e2e: playwright $(EXECUTABLE_E2E) test-e2e: playwright frontend backend
@EXECUTABLE=$(EXECUTABLE_E2E) ./tools/test-e2e.sh $(GITEA_TEST_E2E_FLAGS) @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh run $(GITEA_TEST_E2E_FLAGS)
.PHONY: bench-sqlite
bench-sqlite: integrations.sqlite.test generate-ini-sqlite
GITEA_TEST_CONF=tests/sqlite.ini ./integrations.sqlite.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench .
.PHONY: bench-mysql
bench-mysql: integrations.mysql.test generate-ini-mysql
GITEA_TEST_CONF=tests/mysql.ini ./integrations.mysql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench .
.PHONY: bench-mssql
bench-mssql: integrations.mssql.test generate-ini-mssql
GITEA_TEST_CONF=tests/mssql.ini ./integrations.mssql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench .
.PHONY: bench-pgsql
bench-pgsql: integrations.pgsql.test generate-ini-pgsql
GITEA_TEST_CONF=tests/pgsql.ini ./integrations.pgsql.test -test.cpuprofile=cpu.out -test.run DontRunTests -test.bench .
.PHONY: integration-test-coverage
integration-test-coverage: integrations.cover.test generate-ini-mysql
GITEA_TEST_CONF=tests/mysql.ini ./integrations.cover.test -test.coverprofile=integration.coverage.out
.PHONY: integration-test-coverage-sqlite
integration-test-coverage-sqlite: integrations.cover.sqlite.test generate-ini-sqlite
GITEA_TEST_CONF=tests/sqlite.ini ./integrations.cover.sqlite.test -test.coverprofile=integration.coverage.out
integrations.mysql.test: git-check $(GO_SOURCES)
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.mysql.test
integrations.pgsql.test: git-check $(GO_SOURCES)
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.pgsql.test
integrations.mssql.test: git-check $(GO_SOURCES)
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.mssql.test
integrations.sqlite.test: git-check $(GO_SOURCES)
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -o integrations.sqlite.test -tags '$(TEST_TAGS)'
integrations.cover.test: git-check $(GO_SOURCES)
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -coverpkg $(shell echo $(GO_TEST_PACKAGES) | tr ' ' ',') -o integrations.cover.test
integrations.cover.sqlite.test: git-check $(GO_SOURCES)
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration -coverpkg $(shell echo $(GO_TEST_PACKAGES) | tr ' ' ',') -o integrations.cover.sqlite.test -tags '$(TEST_TAGS)'
.PHONY: migrations.mysql.test
migrations.mysql.test: $(GO_SOURCES) generate-ini-mysql
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.mysql.test
GITEA_TEST_CONF=tests/mysql.ini ./migrations.mysql.test
.PHONY: migrations.pgsql.test
migrations.pgsql.test: $(GO_SOURCES) generate-ini-pgsql
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.pgsql.test
GITEA_TEST_CONF=tests/pgsql.ini ./migrations.pgsql.test
.PHONY: migrations.mssql.test
migrations.mssql.test: $(GO_SOURCES) generate-ini-mssql
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.mssql.test
GITEA_TEST_CONF=tests/mssql.ini ./migrations.mssql.test
.PHONY: migrations.sqlite.test
migrations.sqlite.test: $(GO_SOURCES) generate-ini-sqlite
$(GO) test $(GOTESTFLAGS) -c code.gitea.io/gitea/tests/integration/migration-test -o migrations.sqlite.test -tags '$(TEST_TAGS)'
GITEA_TEST_CONF=tests/sqlite.ini ./migrations.sqlite.test
.PHONY: migrations.individual.mysql.test
migrations.individual.mysql.test: $(GO_SOURCES) generate-ini-mysql
GITEA_TEST_CONF=tests/mysql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES)
.PHONY: migrations.individual.sqlite.test\#%
migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite
GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$*
.PHONY: migrations.individual.pgsql.test
migrations.individual.pgsql.test: $(GO_SOURCES) generate-ini-pgsql
GITEA_TEST_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES)
.PHONY: migrations.individual.pgsql.test\#%
migrations.individual.pgsql.test\#%: $(GO_SOURCES) generate-ini-pgsql
GITEA_TEST_CONF=tests/pgsql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$*
.PHONY: migrations.individual.mssql.test
migrations.individual.mssql.test: $(GO_SOURCES) generate-ini-mssql
GITEA_TEST_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES)
.PHONY: migrations.individual.mssql.test\#%
migrations.individual.mssql.test\#%: $(GO_SOURCES) generate-ini-mssql
GITEA_TEST_CONF=tests/mssql.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$*
.PHONY: migrations.individual.sqlite.test
migrations.individual.sqlite.test: $(GO_SOURCES) generate-ini-sqlite
GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -p 1 $(MIGRATE_TEST_PACKAGES)
.PHONY: migrations.individual.sqlite.test\#%
migrations.individual.sqlite.test\#%: $(GO_SOURCES) generate-ini-sqlite
GITEA_TEST_CONF=tests/sqlite.ini $(GO) test $(GOTESTFLAGS) -tags '$(TEST_TAGS)' code.gitea.io/gitea/models/migrations/$*
.PHONY: check
check: test
.PHONY: install $(TAGS_PREREQ)
install: $(wildcard *.go)
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(LDFLAGS)'
.PHONY: build .PHONY: build
build: frontend backend ## build everything build: frontend backend ## build everything
@@ -663,9 +514,6 @@ ifneq ($(and $(STATIC),$(findstring pam,$(TAGS))),)
endif endif
CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@ CGO_ENABLED="$(CGO_ENABLED)" CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
$(EXECUTABLE_E2E): $(GO_SOURCES) $(FRONTEND_DEST)
CGO_ENABLED=1 $(GO) build $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TEST_TAGS)' -ldflags '-s -w $(EXTLDFLAGS) $(LDFLAGS)' -o $@
.PHONY: release .PHONY: release
release: frontend generate release-windows release-linux release-darwin release-freebsd release-copy release-compress vendor release-sources release-check release: frontend generate release-windows release-linux release-darwin release-freebsd release-copy release-compress vendor release-sources release-check
@@ -730,7 +578,6 @@ deps-backend: ## install backend dependencies
deps-tools: ## install tool dependencies deps-tools: ## install tool dependencies
$(GO) install $(AIR_PACKAGE) & \ $(GO) install $(AIR_PACKAGE) & \
$(GO) install $(EDITORCONFIG_CHECKER_PACKAGE) & \ $(GO) install $(EDITORCONFIG_CHECKER_PACKAGE) & \
$(GO) install $(GOFUMPT_PACKAGE) & \
$(GO) install $(GOLANGCI_LINT_PACKAGE) & \ $(GO) install $(GOLANGCI_LINT_PACKAGE) & \
$(GO) install $(GXZ_PACKAGE) & \ $(GO) install $(GXZ_PACKAGE) & \
$(GO) install $(MISSPELL_PACKAGE) & \ $(GO) install $(MISSPELL_PACKAGE) & \
@@ -761,8 +608,6 @@ update-js: node_modules ## update js dependencies
pnpm exec updates -u -f package.json pnpm exec updates -u -f package.json
rm -rf node_modules pnpm-lock.yaml rm -rf node_modules pnpm-lock.yaml
pnpm install pnpm install
pnpm exec nolyfill install
pnpm install
@touch node_modules @touch node_modules
.PHONY: update-py .PHONY: update-py
@@ -789,10 +634,10 @@ svg: node_modules ## build svg files
.PHONY: svg-check .PHONY: svg-check
svg-check: svg svg-check: svg
@git add $(SVG_DEST_DIR) @git add $(SVG_DEST_DIRS)
@diff=$$(git diff --color=always --cached $(SVG_DEST_DIR)); \ @diff=$$(git diff --color=always --cached $(SVG_DEST_DIRS)); \
if [ -n "$$diff" ]; then \ if [ -n "$$diff" ]; then \
echo "Please run 'make svg' and 'git add $(SVG_DEST_DIR)' and commit the result:"; \ echo "Please run 'make svg' and 'git add $(SVG_DEST_DIRS)' and commit the result:"; \
printf "%s" "$${diff}"; \ printf "%s" "$${diff}"; \
exit 1; \ exit 1; \
fi fi
@@ -816,6 +661,10 @@ generate-gitignore: ## update gitignore files
generate-images: | node_modules ## generate images generate-images: | node_modules ## generate images
cd tools && node generate-images.ts $(TAGS) cd tools && node generate-images.ts $(TAGS)
.PHONY: generate-codemirror-languages
generate-codemirror-languages: | node_modules ## generate codemirror languages
node tools/generate-codemirror-languages.ts
.PHONY: generate-manpage .PHONY: generate-manpage
generate-manpage: ## generate manpage generate-manpage: ## generate manpage
@[ -f gitea ] || make backend @[ -f gitea ] || make backend
@@ -824,11 +673,6 @@ generate-manpage: ## generate manpage
@gzip -9 man/man1/gitea.1 && echo man/man1/gitea.1.gz created @gzip -9 man/man1/gitea.1 && echo man/man1/gitea.1.gz created
@#TODO A small script that formats config-cheat-sheet.en-us.md nicely for use as a config man page @#TODO A small script that formats config-cheat-sheet.en-us.md nicely for use as a config man page
.PHONY: docker
docker:
docker build --disable-content-trust=false -t $(DOCKER_REF) .
# support also build args docker build --build-arg GITEA_VERSION=v1.2.3 --build-arg TAGS="bindata sqlite sqlite_unlock_notify" .
# Disable parallel execution because it would break some targets that don't # Disable parallel execution because it would break some targets that don't
# specify exact dependencies like 'backend' which does currently not depend # specify exact dependencies like 'backend' which does currently not depend
# on 'frontend' to enable Node.js-less builds from source tarballs. # on 'frontend' to enable Node.js-less builds from source tarballs.
+24 -37
View File
@@ -2,8 +2,8 @@
[![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly") [![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly")
[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") [![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
[![](https://goreportcard.com/badge/code.gitea.io/gitea)](https://goreportcard.com/report/code.gitea.io/gitea "Go Report Card") [![](https://goreportcard.com/badge/gitea.dev)](https://goreportcard.com/report/gitea.dev "Go Report Card")
[![](https://pkg.go.dev/badge/code.gitea.io/gitea?status.svg)](https://pkg.go.dev/code.gitea.io/gitea "GoDoc") [![](https://pkg.go.dev/badge/gitea.dev?status.svg)](https://pkg.go.dev/gitea.dev "GoDoc")
[![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release") [![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release")
[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
@@ -14,21 +14,21 @@
## Purpose ## Purpose
The goal of this project is to make the easiest, fastest, and most The goal of Gitea is to make the easiest, fastest, and most painless way of
painless way of setting up a self-hosted Git service. setting up a self-hosted all-in-one software development service,
including Git hosting, code management, code review, issue tracking, project kanban, wiki,
team collaboration, package registry and CI/CD which can reuse GitHub Actions.
As Gitea is written in Go, it works across **all** the platforms and As Gitea is written in Go, it works across **all** the platforms and
architectures that are supported by Go, including Linux, macOS, and architectures that are supported by Go, including Linux, macOS, FreeBSD/OpenBSD and Windows
Windows on x86, amd64, ARM and PowerPC architectures. on x86, amd64, ARM, RISC-V 64 and PowerPC architectures.
This project has been
[forked](https://blog.gitea.com/welcome-to-gitea/) from
[Gogs](https://gogs.io) since November of 2016, but a lot has changed.
For online demonstrations, you can visit [demo.gitea.com](https://demo.gitea.com). For online demonstrations, you can visit [demo.gitea.com](https://demo.gitea.com).
For accessing free Gitea service (with a limited number of repositories), you can visit [gitea.com](https://gitea.com/user/login). For accessing free Gitea service (with a limited number of repositories), you can visit [gitea.com](https://gitea.com/user/login).
To quickly deploy your own dedicated Gitea instance on Gitea Cloud, you can start a free trial at [cloud.gitea.com](https://cloud.gitea.com). To quickly deploy your own dedicated Gitea instance on Gitea Cloud, you can start a free trial at [cloud.gitea.com](https://cloud.gitea.com),
or use container (docker/podman/etc) to deploy on your own server with the [official image](https://hub.docker.com/r/gitea/gitea).
## Documentation ## Documentation
@@ -40,31 +40,12 @@ If you have any suggestions or would like to contribute to it, you can visit the
## Building ## Building
From the root of the source tree, run: See [docs/build-setup.md](docs/build-setup.md) for prerequisites
and [docs/development.md](docs/development.md) for setting up a local development environment, linting, and testing.
TAGS="bindata" make build If you'd like to build from source or make a distribution package, see [docs/build-source.md](docs/build-source.md) for more information.
or if SQLite support is required: After building, you can run `./gitea web` to start the server, or `./gitea help` to see all available commands.
TAGS="bindata sqlite sqlite_unlock_notify" make build
The `build` target is split into two sub-targets:
- `make backend` which requires [Go Stable](https://go.dev/dl/), the required version is defined in [go.mod](/go.mod).
- `make frontend` which requires [Node.js LTS](https://nodejs.org/en/download/) or greater and [pnpm](https://pnpm.io/installation).
Internet connectivity is required to download the go and npm modules. When building from the official source tarballs which include pre-built frontend files, the `frontend` target will not be triggered, making it possible to build without Node.js.
More info: https://docs.gitea.com/installation/install-from-source
## Using
After building, a binary file named `gitea` will be generated in the root of the source tree by default. To run it, use:
./gitea web
> [!NOTE]
> If you're interested in using our APIs, we have experimental support with [documentation](https://docs.gitea.com/api).
## Contributing ## Contributing
@@ -73,7 +54,8 @@ Expected workflow is: Fork -> Patch -> Push -> Pull Request
> [!NOTE] > [!NOTE]
> >
> 1. **YOU MUST READ THE [CONTRIBUTORS GUIDE](CONTRIBUTING.md) BEFORE STARTING TO WORK ON A PULL REQUEST.** > 1. **YOU MUST READ THE [CONTRIBUTORS GUIDE](CONTRIBUTING.md) BEFORE STARTING TO WORK ON A PULL REQUEST.**
> 2. If you have found a vulnerability in the project, please write privately to **security@gitea.io**. Thanks! > 2. New to the codebase? The [development guide](docs/development.md) walks through setting up a local environment and building from source.
> 3. If you have found a vulnerability in the project, please write privately to **security@gitea.io**. Thanks!
## Translating ## Translating
@@ -130,14 +112,19 @@ Support this project by becoming a sponsor. Your logo will show up here with a l
Gitea is pronounced [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY) as in "gi-tea" with a hard g. Gitea is pronounced [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY) as in "gi-tea" with a hard g.
**Why is this not hosted on a Gitea instance?** **How do I configure Gitea?**
We're [working on it](https://github.com/go-gitea/gitea/issues/1029). For dynamic config options, you can change it on your admin panel's configuration section.
For static config options, you can edit your `app.ini` file and resart the instance.
See [app.example.ini](https://github.com/go-gitea/gitea/blob/main/custom/conf/app.example.ini) or [configuration documentation](https://docs.gitea.com/administration/config-cheat-sheet) for more details.
**Where can I find the security patches?** **Where can I find the security patches?**
In the [release log](https://github.com/go-gitea/gitea/releases) or the [change log](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md), search for the keyword `SECURITY` to find the security patches. In the [release log](https://github.com/go-gitea/gitea/releases) or the [change log](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md), search for the keyword `SECURITY` to find the security patches.
(more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq))
## License ## License
This project is licensed under the MIT License. This project is licensed under the MIT License.
@@ -147,7 +134,7 @@ for the full license text.
## Further information ## Further information
<details> <details>
<summary>Looking for an overview of the interface? Check it out!</summary> <summary>Looking for an overview of the interface? Check it out the screenshots!</summary>
### Login/Register Page ### Login/Register Page
+2 -6
View File
@@ -2,8 +2,8 @@
[![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly") [![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly")
[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") [![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
[![](https://goreportcard.com/badge/code.gitea.io/gitea)](https://goreportcard.com/report/code.gitea.io/gitea "Go Report Card") [![](https://goreportcard.com/badge/gitea.dev)](https://goreportcard.com/report/gitea.dev "Go Report Card")
[![](https://pkg.go.dev/badge/code.gitea.io/gitea?status.svg)](https://pkg.go.dev/code.gitea.io/gitea "GoDoc") [![](https://pkg.go.dev/badge/gitea.dev?status.svg)](https://pkg.go.dev/gitea.dev "GoDoc")
[![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release") [![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release")
[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
@@ -38,10 +38,6 @@
TAGS="bindata" make build TAGS="bindata" make build
如果需要 SQLite 支持:
TAGS="bindata sqlite sqlite_unlock_notify" make build
`build` 目标分为两个子目标: `build` 目标分为两个子目标:
- `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定义。 - `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定义。
+2 -6
View File
@@ -2,8 +2,8 @@
[![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly") [![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly")
[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea") [![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
[![](https://goreportcard.com/badge/code.gitea.io/gitea)](https://goreportcard.com/report/code.gitea.io/gitea "Go Report Card") [![](https://goreportcard.com/badge/gitea.dev)](https://goreportcard.com/report/gitea.dev "Go Report Card")
[![](https://pkg.go.dev/badge/code.gitea.io/gitea?status.svg)](https://pkg.go.dev/code.gitea.io/gitea "GoDoc") [![](https://pkg.go.dev/badge/gitea.dev?status.svg)](https://pkg.go.dev/gitea.dev "GoDoc")
[![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release") [![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release")
[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source") [![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea") [![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
@@ -38,10 +38,6 @@
TAGS="bindata" make build TAGS="bindata" make build
如果需要 SQLite 支援:
TAGS="bindata sqlite sqlite_unlock_notify" make build
`build` 目標分為兩個子目標: `build` 目標分為兩個子目標:
- `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定義。 - `make backend` 需要 [Go Stable](https://go.dev/dl/),所需版本在 [go.mod](/go.mod) 中定義。
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@ import (
"fmt" "fmt"
"os" "os"
"code.gitea.io/gitea/modules/assetfs" "gitea.dev/modules/assetfs"
) )
func main() { func main() {
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"code.gitea.io/gitea/modules/json" "gitea.dev/modules/json"
) )
const ( const (
@@ -1,3 +1,6 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build ignore //go:build ignore
package main package main
@@ -15,7 +18,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"code.gitea.io/gitea/modules/util" "gitea.dev/modules/util"
) )
func main() { func main() {
@@ -29,8 +29,8 @@ var primaryLicenseRe = regexp.MustCompile(`^(?i)(LICEN[SC]E|COPYING)$`)
// ignoredNames are LicenseEntry.Name values to exclude from the output. // ignoredNames are LicenseEntry.Name values to exclude from the output.
var ignoredNames = map[string]bool{ var ignoredNames = map[string]bool{
"code.gitea.io/gitea": true, "gitea.dev": true,
"code.gitea.io/gitea/options/license": true, "gitea.dev/options/license": true,
} }
var excludedExt = map[string]bool{ var excludedExt = map[string]bool{
@@ -0,0 +1,97 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// generate-openapi converts Gitea's Swagger 2.0 spec into an OpenAPI 3.0 spec.
//
// Gitea generates a Swagger 2.0 spec from code annotations (make generate-swagger).
// This tool converts it to OAS3 so that SDK generators and tools that require
// OAS3 (e.g. progenitor for Rust) can consume it directly. The conversion also
// deduplicates inline enum definitions into named schema components, producing
// cleaner SDK output with proper enum types instead of anonymous strings.
//
// Run: go run build/generate-openapi.go
// Output: templates/swagger/v1_openapi3_json.tmpl
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"regexp"
"sort"
"strings"
"gitea.dev/build/openapi3gen"
"github.com/getkin/kin-openapi/openapi3"
)
const (
swaggerSpecPath = "templates/swagger/v1_json.tmpl"
openapi3OutPath = "templates/swagger/v1_openapi3_json.tmpl"
appSubUrlVar = "{{.SwaggerAppSubUrl}}"
appVerVar = "{{.SwaggerAppVer}}"
appSubUrlPlaceholder = "GITEA_APP_SUB_URL_PLACEHOLDER"
appVerPlaceholder = "0.0.0-gitea-placeholder"
)
var (
appSubUrlRe = regexp.MustCompile(regexp.QuoteMeta(appSubUrlVar))
appVerRe = regexp.MustCompile(regexp.QuoteMeta(appVerVar))
enumScanDirs = []string{
"modules/structs",
"modules/commitstatus",
}
)
func main() {
astEnumMap, err := openapi3gen.ScanSwaggerEnumTypes(enumScanDirs)
if err != nil {
log.Fatalf("scanning swagger:enum annotations: %v", err)
}
names := make([]string, 0, len(astEnumMap))
for _, ns := range astEnumMap {
names = append(names, ns...)
}
sort.Strings(names)
fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", "))
data, err := os.ReadFile(swaggerSpecPath)
if err != nil {
log.Fatalf("reading swagger spec: %v", err)
}
cleaned := appSubUrlRe.ReplaceAll(data, []byte(appSubUrlPlaceholder))
cleaned = appVerRe.ReplaceAll(cleaned, []byte(appVerPlaceholder))
oas3, err := openapi3gen.Convert(cleaned, astEnumMap)
if err != nil {
log.Fatalf("converting to openapi 3.0: %v", err)
}
oas3.Servers = openapi3.Servers{
{URL: appSubUrlPlaceholder + "/api/v1"},
}
out, err := json.MarshalIndent(oas3, "", " ")
if err != nil {
log.Fatalf("marshaling openapi 3.0: %v", err)
}
result := strings.ReplaceAll(string(out), appSubUrlPlaceholder, appSubUrlVar)
result = strings.ReplaceAll(result, appVerPlaceholder, appVerVar)
result = strings.TrimSpace(result)
if err := os.WriteFile(openapi3OutPath, []byte(result), 0o644); err != nil {
log.Fatalf("writing openapi 3.0 spec: %v", err)
}
fmt.Printf("Generated %s\n", openapi3OutPath)
}
@@ -0,0 +1,386 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package openapi3gen
import (
"fmt"
"regexp"
"sort"
"strings"
"gitea.dev/modules/json"
"github.com/getkin/kin-openapi/openapi2"
"github.com/getkin/kin-openapi/openapi2conv"
"github.com/getkin/kin-openapi/openapi3"
)
// rxDeprecated matches "deprecated" as a word at the start of a description
// or preceded by whitespace/punctuation that indicates a leading marker (e.g.
// "Deprecated: true", "deprecated (use X instead)"). Rejects negated phrases
// like "not deprecated" or "previously deprecated, now supported".
var rxDeprecated = regexp.MustCompile(`(?i)(?:^|[\n.;])\s*deprecated\b`)
// Convert parses a Swagger 2.0 spec and returns an OAS3 spec, applying
// Gitea-specific post-processing: file-schema fixups, URI formats,
// deprecated flags, and shared-enum extraction.
//
// astEnumMap is a value-set-key → Go-type-name(s) map (built by
// ScanSwaggerEnumTypes). When a value set is shared by multiple Go types,
// per-property disambiguation uses the x-go-enum-desc extension. If a shared
// enum in the spec has no matching entry, Convert returns an error — no
// fallback naming.
func Convert(swaggerJSON []byte, astEnumMap map[string][]string) (*openapi3.T, error) {
var swagger2 openapi2.T
if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil {
return nil, fmt.Errorf("parsing swagger 2.0: %w", err)
}
oas3, err := openapi2conv.ToV3(&swagger2)
if err != nil {
return nil, fmt.Errorf("converting to openapi 3.0: %w", err)
}
fixFileSchemas(oas3)
addURIFormats(oas3)
addDeprecatedFlags(oas3)
if err := extractSharedEnums(oas3, astEnumMap); err != nil {
return nil, err
}
return oas3, nil
}
func fixFileSchemas(doc *openapi3.T) {
for _, pathItem := range doc.Paths.Map() {
for _, op := range []*openapi3.Operation{
pathItem.Get, pathItem.Post, pathItem.Put, pathItem.Patch,
pathItem.Delete, pathItem.Head, pathItem.Options, pathItem.Trace,
} {
if op == nil {
continue
}
for _, resp := range op.Responses.Map() {
if resp.Value == nil {
continue
}
for _, mediaType := range resp.Value.Content {
fixSchema(mediaType.Schema)
}
}
if op.RequestBody != nil && op.RequestBody.Value != nil {
for _, mediaType := range op.RequestBody.Value.Content {
fixSchema(mediaType.Schema)
}
}
}
}
}
// fixSchema rewrites any "type: file" schemas to the OAS3 equivalent
// (type: string, format: binary), recursing into Properties, Items, and
// AllOf/OneOf/AnyOf/Not branches. $ref nodes are skipped so shared schemas
// are rewritten exactly once when visited through their declaration.
func fixSchema(ref *openapi3.SchemaRef) {
if ref == nil || ref.Value == nil || ref.Ref != "" {
return
}
s := ref.Value
if s.Type.Is("file") {
s.Type = &openapi3.Types{"string"}
s.Format = "binary"
}
for _, p := range s.Properties {
fixSchema(p)
}
fixSchema(s.Items)
for _, sub := range s.AllOf {
fixSchema(sub)
}
for _, sub := range s.OneOf {
fixSchema(sub)
}
for _, sub := range s.AnyOf {
fixSchema(sub)
}
fixSchema(s.Not)
}
// addURIFormats sets format: uri on string properties whose names indicate
// they hold URLs. This information is lost in Swagger 2.0 but is valuable
// for code generators.
func addURIFormats(doc *openapi3.T) {
if doc.Components == nil {
return
}
for _, schemaRef := range doc.Components.Schemas {
if schemaRef.Value == nil {
continue
}
for propName, propRef := range schemaRef.Value.Properties {
if propRef == nil || propRef.Value == nil || propRef.Ref != "" {
continue
}
prop := propRef.Value
if !prop.Type.Is("string") || prop.Format != "" {
continue
}
if isURLProperty(propName) {
prop.Format = "uri"
}
}
}
}
func isURLProperty(name string) bool {
if strings.HasSuffix(name, "_url") {
return true
}
switch name {
case "url", "html_url", "clone_url":
return true
}
return false
}
// addDeprecatedFlags sets deprecated: true on schema properties whose
// description starts with a "deprecated" marker (e.g. "Deprecated: true"
// or "deprecated (use X instead)"). Does not match negated phrases.
func addDeprecatedFlags(doc *openapi3.T) {
if doc.Components == nil {
return
}
for _, schemaRef := range doc.Components.Schemas {
if schemaRef.Value == nil {
continue
}
for _, propRef := range schemaRef.Value.Properties {
if propRef == nil || propRef.Value == nil || propRef.Ref != "" {
continue
}
if rxDeprecated.MatchString(propRef.Value.Description) {
propRef.Value.Deprecated = true
}
}
}
}
type enumUsage struct {
schemaName string
propName string
propRef *openapi3.SchemaRef
inItems bool
}
// extractSharedEnums finds identical enum arrays used by multiple schema
// properties, creates a standalone named schema for each, and replaces
// the inline enums with $ref pointers.
//
// If the derived enum name collides with an existing component schema, or
// no // swagger:enum annotation matches the value set, generation aborts
// with an actionable error — there are no silent fallbacks.
func extractSharedEnums(doc *openapi3.T, astEnumMap map[string][]string) error {
if doc.Components == nil {
return nil
}
type groupKey struct {
valueSet string
typeName string
}
enumGroups := map[groupKey][]enumUsage{}
groupOrder := []groupKey{} // deterministic iteration
addUsage := func(key groupKey, u enumUsage) {
if _, seen := enumGroups[key]; !seen {
groupOrder = append(groupOrder, key)
}
enumGroups[key] = append(enumGroups[key], u)
}
for schemaName, schemaRef := range doc.Components.Schemas {
if schemaRef.Value == nil {
continue
}
for propName, propRef := range schemaRef.Value.Properties {
if propRef == nil || propRef.Value == nil || propRef.Ref != "" {
continue
}
if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") {
key := groupKey{
valueSet: EnumKey(propRef.Value.Enum),
typeName: extractEnumTypeName(propRef.Value, astEnumMap),
}
addUsage(key, enumUsage{schemaName, propName, propRef, false})
}
if propRef.Value.Type.Is("array") && propRef.Value.Items != nil &&
propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" &&
len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") {
key := groupKey{
valueSet: EnumKey(propRef.Value.Items.Value.Enum),
typeName: extractEnumTypeName(propRef.Value.Items.Value, astEnumMap),
}
addUsage(key, enumUsage{schemaName, propName, propRef, true})
}
}
}
for _, key := range groupOrder {
usages := enumGroups[key]
if len(usages) < 2 {
continue
}
enumName, err := deriveEnumName(key.valueSet, key.typeName, usages, astEnumMap)
if err != nil {
return err
}
if _, exists := doc.Components.Schemas[enumName]; exists {
return fmt.Errorf("enum name collision: %s already exists as a component schema", enumName)
}
var enumValues []any
if usages[0].inItems {
enumValues = usages[0].propRef.Value.Items.Value.Enum
} else {
enumValues = usages[0].propRef.Value.Enum
}
doc.Components.Schemas[enumName] = &openapi3.SchemaRef{
Value: &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enumValues,
},
}
ref := "#/components/schemas/" + enumName
for _, usage := range usages {
if usage.inItems {
usage.propRef.Value.Items = &openapi3.SchemaRef{Ref: ref}
} else {
old := usage.propRef.Value
if old.Description == "" && !old.Deprecated && old.Format == "" {
usage.propRef.Ref = ref
usage.propRef.Value = nil
} else {
usage.propRef.Value = &openapi3.Schema{
AllOf: openapi3.SchemaRefs{
{Ref: ref},
},
Description: old.Description,
Deprecated: old.Deprecated,
Format: old.Format,
}
}
}
}
}
return nil
}
// deriveEnumName looks up a shared enum's Go type name. If typeName is
// non-empty (because we recovered it from x-go-enum-desc), it is used
// directly. Otherwise the value-set must map to exactly one known type. On
// failure, returns an error identifying the offending properties.
func deriveEnumName(key, typeName string, usages []enumUsage, astEnumMap map[string][]string) (string, error) {
if typeName != "" {
return typeName, nil
}
names := astEnumMap[key]
if len(names) == 1 {
return names[0], nil
}
props := map[string]bool{}
for _, u := range usages {
props[fmt.Sprintf("%s.%s", u.schemaName, u.propName)] = true
}
propList := make([]string, 0, len(props))
for p := range props {
propList = append(propList, p)
}
if len(names) > 1 {
return "", fmt.Errorf(
"value-set %q is shared by multiple swagger:enum types %v and could not be disambiguated for properties: %v; "+
"ensure go-swagger emits x-go-enum-desc for those properties",
key, names, propList,
)
}
return "", fmt.Errorf(
"no swagger:enum annotation matches value-set %q used by %d properties: %v; "+
"fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus",
key, len(usages), propList,
)
}
// extractEnumTypeName recovers the Go type name a schema's enum came from by
// parsing the property's x-go-enum-desc extension. go-swagger emits one line
// per value as "<value> <ConstName>[ <free text>]"; the type is the longest
// common prefix of the const names, narrowed to the candidate set in
// astEnumMap. Returns "" if extraction is inconclusive.
func extractEnumTypeName(s *openapi3.Schema, astEnumMap map[string][]string) string {
if s == nil || s.Extensions == nil {
return ""
}
raw, ok := s.Extensions["x-go-enum-desc"]
if !ok {
return ""
}
desc, ok := raw.(string)
if !ok {
return ""
}
candidates := astEnumMap[EnumKey(s.Enum)]
if len(candidates) == 0 {
return ""
}
// Collect the const names (second whitespace-separated field per line).
var consts []string
for line := range strings.SplitSeq(desc, "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 {
consts = append(consts, fields[1])
}
}
if len(consts) == 0 {
return ""
}
// A candidate matches when it is a prefix of every const name AND the
// first character after the prefix is an uppercase ASCII letter — this
// rejects e.g. "Alpha" matching "Alphabet" (suffix "bet" starts lower)
// while still accepting both "Alpha" and "AlphaPlus" against "AlphaPlusX"
// (both prefixes valid). The most specific (longest) wins; ties return
// "" so deriveEnumName surfaces the ambiguity rather than silently
// picking a winner.
ordered := append([]string(nil), candidates...)
sort.Slice(ordered, func(i, j int) bool { return len(ordered[i]) > len(ordered[j]) })
var matches []string
for _, name := range ordered {
ok := true
for _, c := range consts {
if !strings.HasPrefix(c, name) {
ok = false
break
}
suffix := c[len(name):]
// Empty suffix means the const name exactly equals the type name — valid exact match.
// A non-empty suffix must begin with an uppercase letter to reject incidental
// prefix matches (e.g. "Alpha" should not match "Alphabet").
if len(suffix) > 0 && (suffix[0] < 'A' || suffix[0] > 'Z') {
ok = false
break
}
}
if ok {
matches = append(matches, name)
}
}
if len(matches) == 0 {
return ""
}
if len(matches) > 1 && len(matches[0]) == len(matches[1]) {
return ""
}
return matches[0]
}
@@ -0,0 +1,218 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package openapi3gen
import (
"strings"
"testing"
"github.com/getkin/kin-openapi/openapi3"
)
func TestDeriveEnumName_hit(t *testing.T) {
key := EnumKey([]any{"red", "green", "blue"})
astMap := map[string][]string{key: {"Color"}}
usages := []enumUsage{{schemaName: "Paint", propName: "color"}}
got, err := deriveEnumName(key, "", usages, astMap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "Color" {
t.Fatalf("got %q, want %q", got, "Color")
}
}
func TestDeriveEnumName_miss(t *testing.T) {
key := EnumKey([]any{"x", "y"})
usages := []enumUsage{{schemaName: "Thing", propName: "kind"}}
_, err := deriveEnumName(key, "", usages, map[string][]string{})
if err == nil {
t.Fatal("expected miss error, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "Thing.kind") {
t.Fatalf("error %q should list the missing usage", msg)
}
if !strings.Contains(msg, "swagger:enum") {
t.Fatalf("error %q should hint at the fix", msg)
}
}
func TestExtractSharedEnums_usesASTMap(t *testing.T) {
doc := &openapi3.T{
Components: &openapi3.Components{
Schemas: openapi3.Schemas{
"A": {Value: &openapi3.Schema{
Type: &openapi3.Types{"object"},
Properties: openapi3.Schemas{
"color": {Value: &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: []any{"red", "green", "blue"},
}},
},
}},
"B": {Value: &openapi3.Schema{
Type: &openapi3.Types{"object"},
Properties: openapi3.Schemas{
"color": {Value: &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: []any{"red", "green", "blue"},
}},
},
}},
},
},
}
astMap := map[string][]string{EnumKey([]any{"red", "green", "blue"}): {"Color"}}
if err := extractSharedEnums(doc, astMap); err != nil {
t.Fatalf("extractSharedEnums: %v", err)
}
if _, ok := doc.Components.Schemas["Color"]; !ok {
t.Fatalf("expected Color schema to be extracted")
}
}
func TestFixFileSchemas_recursesIntoNested(t *testing.T) {
fileType := func() *openapi3.SchemaRef {
return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"file"}}}
}
doc := &openapi3.T{
Paths: openapi3.NewPaths(),
}
doc.Paths.Set("/upload", &openapi3.PathItem{
Post: &openapi3.Operation{
RequestBody: &openapi3.RequestBodyRef{
Value: &openapi3.RequestBody{
Content: openapi3.Content{
"multipart/form-data": {
Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{
Type: &openapi3.Types{"object"},
Properties: openapi3.Schemas{
"attachment": fileType(),
"items": {Value: &openapi3.Schema{
Type: &openapi3.Types{"array"},
Items: fileType(),
}},
"alt": {Value: &openapi3.Schema{
AllOf: openapi3.SchemaRefs{fileType()},
}},
"one": {Value: &openapi3.Schema{
OneOf: openapi3.SchemaRefs{fileType()},
}},
"any": {Value: &openapi3.Schema{
AnyOf: openapi3.SchemaRefs{fileType()},
}},
"not": {Value: &openapi3.Schema{
Not: fileType(),
}},
},
}},
},
},
},
},
Responses: openapi3.NewResponses(),
},
})
fixFileSchemas(doc)
props := doc.Paths.Value("/upload").Post.RequestBody.Value.Content["multipart/form-data"].Schema.Value.Properties
if !props["attachment"].Value.Type.Is("string") || props["attachment"].Value.Format != "binary" {
t.Errorf("nested property not fixed: %+v", props["attachment"].Value)
}
if !props["items"].Value.Items.Value.Type.Is("string") || props["items"].Value.Items.Value.Format != "binary" {
t.Errorf("array items not fixed: %+v", props["items"].Value.Items.Value)
}
if !props["alt"].Value.AllOf[0].Value.Type.Is("string") || props["alt"].Value.AllOf[0].Value.Format != "binary" {
t.Errorf("allOf branch not fixed: %+v", props["alt"].Value.AllOf[0].Value)
}
if !props["one"].Value.OneOf[0].Value.Type.Is("string") || props["one"].Value.OneOf[0].Value.Format != "binary" {
t.Errorf("oneOf branch not fixed: %+v", props["one"].Value.OneOf[0].Value)
}
if !props["any"].Value.AnyOf[0].Value.Type.Is("string") || props["any"].Value.AnyOf[0].Value.Format != "binary" {
t.Errorf("anyOf branch not fixed: %+v", props["any"].Value.AnyOf[0].Value)
}
if !props["not"].Value.Not.Value.Type.Is("string") || props["not"].Value.Not.Value.Format != "binary" {
t.Errorf("not branch not fixed: %+v", props["not"].Value.Not.Value)
}
}
func TestExtractEnumTypeName_TeamVisibility(t *testing.T) {
enum := []any{"public", "limited", "private"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"UserVisibility", "TeamVisibility"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
},
}
if got := extractEnumTypeName(schema, astMap); got != "TeamVisibility" {
t.Fatalf("got %q, want %q", got, "TeamVisibility")
}
}
func TestExtractEnumTypeName_ambiguousPrefixTie(t *testing.T) {
enum := []any{"one", "two"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"AB", "AC"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "one ABOne\ntwo ACTwo",
},
}
if got := extractEnumTypeName(schema, astMap); got != "" {
t.Fatalf("got %q, want empty string for ambiguous tie", got)
}
}
func TestExtractEnumTypeName_rejectsIncidentalPrefix(t *testing.T) {
enum := []any{"a", "b"}
key := EnumKey(enum)
astMap := map[string][]string{key: {"Alpha", "Alphabet"}}
schema := &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: enum,
Extensions: map[string]any{
"x-go-enum-desc": "a AlphabetA\nb AlphabetB",
},
}
if got := extractEnumTypeName(schema, astMap); got != "Alphabet" {
t.Fatalf("got %q, want %q", got, "Alphabet")
}
}
func TestExtractSharedEnums_missReturnsError(t *testing.T) {
doc := &openapi3.T{
Components: &openapi3.Components{
Schemas: openapi3.Schemas{
"A": {Value: &openapi3.Schema{
Type: &openapi3.Types{"object"},
Properties: openapi3.Schemas{
"color": {Value: &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: []any{"red", "green"},
}},
},
}},
"B": {Value: &openapi3.Schema{
Type: &openapi3.Types{"object"},
Properties: openapi3.Schemas{
"color": {Value: &openapi3.Schema{
Type: &openapi3.Types{"string"},
Enum: []any{"red", "green"},
}},
},
}},
},
},
}
if err := extractSharedEnums(doc, map[string][]string{}); err == nil {
t.Fatal("expected miss error")
}
}
@@ -0,0 +1,192 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package openapi3gen converts Gitea's Swagger 2.0 spec to an OpenAPI 3.0
// spec. It discovers Go enum type names by scanning swagger:enum annotations
// in the source tree, then names extracted shared-enum schemas accordingly.
package openapi3gen
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// EnumKey returns a canonical key for a set of enum values: values are
// stringified, sorted, and joined with "|". Used to match enum value sets
// across spec properties and scanned Go type declarations.
func EnumKey(values []any) string {
strs := make([]string, len(values))
for i, v := range values {
strs[i] = fmt.Sprintf("%v", v)
}
sort.Strings(strs)
return strings.Join(strs, "|")
}
var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`)
// ScanSwaggerEnumTypes walks .go files under each dir and returns a map from
// a canonical value-set key (see EnumKey) to the Go type names declared with
// // swagger:enum TypeName. Multiple type names per key are allowed (e.g.
// distinct enum types that happen to share a value set such as
// {public, limited, private}); callers must disambiguate per-usage (typically
// by parsing the property's x-go-enum-desc extension to recover the const
// type prefix).
//
// Returns an error on parse failure or on an annotation for a type whose
// constants can't be extracted.
func ScanSwaggerEnumTypes(dirs []string) (map[string][]string, error) {
fset := token.NewFileSet()
parsed := []*ast.File{}
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", dir, err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
continue
}
if strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
path := filepath.Join(dir, entry.Name())
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
parsed = append(parsed, file)
}
}
enumTypes := map[string]string{} // typeName → "" (presence marker)
enumValues := map[string][]any{} // typeName → values
// Pass 1: collect every // swagger:enum TypeName declaration.
for _, file := range parsed {
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.TYPE {
continue
}
if err := collectEnumType(gd, enumTypes); err != nil {
return nil, fmt.Errorf("%s: %w", fset.Position(gd.Pos()).Filename, err)
}
}
}
// Pass 2: collect const values; now every annotated type is visible.
for _, file := range parsed {
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.CONST {
continue
}
collectEnumValues(gd, enumTypes, enumValues)
}
}
result := map[string][]string{}
for typeName := range enumTypes {
values, ok := enumValues[typeName]
if !ok || len(values) == 0 {
return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName)
}
key := EnumKey(values)
result[key] = append(result[key], typeName)
}
for key, names := range result {
sort.Strings(names)
result[key] = names
}
return result, nil
}
// collectEnumType scans a `type` GenDecl for // swagger:enum annotations,
// handling both the lone form (`// swagger:enum Foo\n type Foo string`)
// where the comment group is attached to the GenDecl, and the grouped form:
//
// type (
// // swagger:enum Foo
// Foo string
// )
//
// where the comment group is attached to each TypeSpec. Caveat: Go's parser
// only attaches a CommentGroup when it is immediately adjacent to the decl.
// A blank line (not a `//` continuation line) between the comment and the
// declaration drops the Doc, so annotations MUST sit directly above their
// type. All current annotated files obey this — the rule is noted here so
// a future edit that inserts a blank line fails fast rather than silently.
func collectEnumType(gd *ast.GenDecl, enumTypes map[string]string) error {
if err := registerEnumAnnotation(gd.Doc, gd.Specs, enumTypes); err != nil {
return err
}
for _, spec := range gd.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok || ts.Doc == nil {
continue
}
if err := registerEnumAnnotation(ts.Doc, []ast.Spec{ts}, enumTypes); err != nil {
return err
}
}
return nil
}
func registerEnumAnnotation(doc *ast.CommentGroup, specs []ast.Spec, enumTypes map[string]string) error {
if doc == nil {
return nil
}
matches := rxSwaggerEnum.FindStringSubmatch(doc.Text())
if len(matches) < 2 {
return nil
}
annotated := matches[1]
for _, spec := range specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
if ts.Name.Name == annotated {
enumTypes[annotated] = ""
return nil
}
}
return fmt.Errorf("swagger:enum %s: no type declaration with that name in the same decl group; check for a typo", annotated)
}
func collectEnumValues(gd *ast.GenDecl, enumTypes map[string]string, enumValues map[string][]any) {
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || vs.Type == nil {
continue
}
ident, ok := vs.Type.(*ast.Ident)
if !ok {
continue
}
if _, isEnum := enumTypes[ident.Name]; !isEnum {
continue
}
for _, val := range vs.Values {
lit, ok := val.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
unquoted, err := strconv.Unquote(lit.Value)
if err != nil {
continue
}
enumValues[ident.Name] = append(enumValues[ident.Name], unquoted)
}
}
}
@@ -0,0 +1,249 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package openapi3gen
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func single(got map[string][]string, key string) string {
v := got[key]
if len(v) != 1 {
return ""
}
return v[0]
}
func TestEnumKey_sortsAndJoins(t *testing.T) {
key := EnumKey([]any{"b", "a", "c"})
if key != "a|b|c" {
t.Fatalf("EnumKey = %q, want %q", key, "a|b|c")
}
}
func TestEnumKey_handlesNonStringValues(t *testing.T) {
key := EnumKey([]any{2, 1, 3})
if key != "1|2|3" {
t.Fatalf("EnumKey = %q, want %q", key, "1|2|3")
}
}
func TestScanSwaggerEnumTypes_basic(t *testing.T) {
dir := t.TempDir()
src := `package fixture
// Color is a primary color.
// swagger:enum Color
type Color string
const (
ColorRed Color = "red"
ColorGreen Color = "green"
ColorBlue Color = "blue"
)
`
if err := os.WriteFile(filepath.Join(dir, "color.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
got, err := ScanSwaggerEnumTypes([]string{dir})
if err != nil {
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
wantKey := EnumKey([]any{"red", "green", "blue"})
if single(got, wantKey) != "Color" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color")
}
}
func TestScanSwaggerEnumTypes_orphanAnnotation(t *testing.T) {
dir := t.TempDir()
src := `package fixture
// swagger:enum Sttype
type StateType string
const (
StateOpen StateType = "open"
)
`
if err := os.WriteFile(filepath.Join(dir, "typo.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
_, err := ScanSwaggerEnumTypes([]string{dir})
if err == nil {
t.Fatal("expected error for annotation referencing a non-matching type name")
}
if !strings.Contains(err.Error(), "Sttype") {
t.Fatalf("error %q should mention the typo'd name Sttype", err.Error())
}
}
func TestScanSwaggerEnumTypes_collision(t *testing.T) {
dir := t.TempDir()
src := `package fixture
// swagger:enum Alpha
type Alpha string
const (
AlphaX Alpha = "x"
AlphaY Alpha = "y"
)
// swagger:enum Beta
type Beta string
const (
BetaX Beta = "x"
BetaY Beta = "y"
)
`
if err := os.WriteFile(filepath.Join(dir, "dup.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
got, err := ScanSwaggerEnumTypes([]string{dir})
if err != nil {
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
key := EnumKey([]any{"x", "y"})
names := got[key]
if !slices.Equal(names, []string{"Alpha", "Beta"}) {
t.Fatalf("map[%q] = %v, want [Alpha Beta]", key, names)
}
}
func TestScanSwaggerEnumTypes_parseFailure(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "bad.go"), []byte("package fixture\nfunc Foo() {"), 0o644); err != nil {
t.Fatal(err)
}
_, err := ScanSwaggerEnumTypes([]string{dir})
if err == nil {
t.Fatal("expected parse error, got nil")
}
}
func TestScanSwaggerEnumTypes_annotationWithoutConsts(t *testing.T) {
dir := t.TempDir()
src := `package fixture
// swagger:enum Lonely
type Lonely string
`
if err := os.WriteFile(filepath.Join(dir, "lonely.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
_, err := ScanSwaggerEnumTypes([]string{dir})
if err == nil {
t.Fatal("expected error for annotation without consts")
}
if !strings.Contains(err.Error(), "Lonely") {
t.Fatalf("error %q should mention Lonely", err.Error())
}
}
func TestScanSwaggerEnumTypes_constsAndTypeInDifferentFiles(t *testing.T) {
dir := t.TempDir()
// Name ordering: `a_consts.go` < `b_type.go`, so readdir returns consts first.
// Old single-pass scanner would miss the values; two-pass must not.
constsSrc := `package fixture
const (
HueA Hue = "a"
HueB Hue = "b"
)
`
typeSrc := `package fixture
// swagger:enum Hue
type Hue string
`
if err := os.WriteFile(filepath.Join(dir, "a_consts.go"), []byte(constsSrc), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "b_type.go"), []byte(typeSrc), 0o644); err != nil {
t.Fatal(err)
}
got, err := ScanSwaggerEnumTypes([]string{dir})
if err != nil {
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
wantKey := EnumKey([]any{"a", "b"})
if single(got, wantKey) != "Hue" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue")
}
}
func TestScanSwaggerEnumTypes_constsBeforeType(t *testing.T) {
dir := t.TempDir()
src := `package fixture
const (
ShadeDark Shade = "dark"
ShadeLight Shade = "light"
)
// swagger:enum Shade
type Shade string
`
if err := os.WriteFile(filepath.Join(dir, "shade.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
got, err := ScanSwaggerEnumTypes([]string{dir})
if err != nil {
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
wantKey := EnumKey([]any{"dark", "light"})
if single(got, wantKey) != "Shade" {
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade")
}
}
func TestScanSwaggerEnumTypes_groupedTypeDecl(t *testing.T) {
dir := t.TempDir()
src := `package fixture
type (
// swagger:enum Color
Color string
// swagger:enum Shade
Shade string
)
const (
ColorRed Color = "red"
ColorBlue Color = "blue"
)
const (
ShadeDark Shade = "dark"
ShadeLight Shade = "light"
)
`
if err := os.WriteFile(filepath.Join(dir, "grouped.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
got, err := ScanSwaggerEnumTypes([]string{dir})
if err != nil {
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
}
colorKey := EnumKey([]any{"red", "blue"})
shadeKey := EnumKey([]any{"dark", "light"})
if single(got, colorKey) != "Color" {
t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color")
}
if single(got, shadeKey) != "Shade" {
t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade")
}
}
@@ -1,24 +0,0 @@
#!/bin/sh
set -e
if [ ! -f ./build/test-env-check.sh ]; then
echo "${0} can only be executed in gitea source root directory"
exit 1
fi
echo "check uid ..."
# the uid of gitea defined in "https://gitea.com/gitea/test-env" is 1000
gitea_uid=$(id -u gitea)
if [ "$gitea_uid" != "1000" ]; then
echo "The uid of linux user 'gitea' is expected to be 1000, but it is $gitea_uid"
exit 1
fi
cur_uid=$(id -u)
if [ "$cur_uid" != "0" -a "$cur_uid" != "$gitea_uid" ]; then
echo "The uid of current linux user is expected to be 0 or $gitea_uid, but it is $cur_uid"
exit 1
fi
@@ -1,11 +0,0 @@
#!/bin/sh
set -e
if [ ! -f ./build/test-env-prepare.sh ]; then
echo "${0} can only be executed in gitea source root directory"
exit 1
fi
echo "change the owner of files to gitea ..."
chown -R gitea:gitea .
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"context" "context"
"fmt" "fmt"
"code.gitea.io/gitea/modules/private" "gitea.dev/modules/private"
"code.gitea.io/gitea/modules/setting" "gitea.dev/modules/setting"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
+6 -6
View File
@@ -8,12 +8,12 @@ import (
"context" "context"
"fmt" "fmt"
"code.gitea.io/gitea/models/db" "gitea.dev/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "gitea.dev/models/repo"
"code.gitea.io/gitea/modules/git" "gitea.dev/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "gitea.dev/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "gitea.dev/modules/log"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "gitea.dev/modules/repository"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"os" "os"
"text/tabwriter" "text/tabwriter"
auth_model "code.gitea.io/gitea/models/auth" auth_model "gitea.dev/models/auth"
"code.gitea.io/gitea/models/db" "gitea.dev/models/db"
auth_service "code.gitea.io/gitea/services/auth" auth_service "gitea.dev/services/auth"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )

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