Gitea/Forgejo Package Uploader

Gitea and Forgejo are pretty rad Git servers which also ship with package registries for a bunch of different languages. In a language such as .NET uploading a package is as simple as registering the server/source:

dotnet nuget add source --name my-nuget --username user --password password

Followed by a publishing command:

dotnet nuget push --source my-nuget some-package.nupkg

Most of the Gitea/Forgejo's package registries can be accessed by a simple POST call, so I started to fiddle around with a Python script called forgejo-push which could give me the same kind of neat experience when dealing with other registries such as Debian, Red Hat or the generic package registry.

So let's start with the ~/.config/forgejo-push.conf configuration file:

url =
token = some-token

url =
token = another-token

url =
token = some-other-token

Follow it up with some examples:

# Upload all .deb files using a recursive glob pattern:
forgejo-push --glob '**/*.deb' --section 'my-debian'

# Upload a single .rpm files:
forgejo-push --glob 'my-package.rpm' --section 'my-rpm'

# Do not throw an error when a package already exists:
forgejo-push --glob 'my-package.rpm' --section 'my-rpm' --skip-duplicate

# The generic registry is different since it requires more parameters, so we
# squeeze them in using a hacky --postfix parameter:
forgejo-push --glob 'artifacts/my-binary1' --section 'my-generic' --postfix 'my-pkg/1.0.0/my-binary1'
forgejo-push --glob 'artifacts/my-binary2' --section 'my-generic' --postfix 'my-pkg/1.0.0/my-binary2'

# The --postfix flag also supports a {file_name} placeholder, so the above two
# lines can be changed to:
forgejo-push --glob 'artifacts/my-*' --section 'my-generic' --postfix 'my-pkg/1.0.0/{file_name}'

And finally, show the code in all its bare and unpolished glory:

#!/usr/bin/env python3

import argparse
import collections
import configparser
import glob
import os
import requests

class Config(collections.namedtuple('Config', 'url token')):
    __slots__ = ()

def parse_config(text):
    config_parser = configparser.ConfigParser()
    config = {}
    for section in config_parser.sections():
        section_data = config_parser[section]
        url = section_data.get('url')
        token = section_data.get('token')
        config[section] = Config(url, token)
    return config

def load_config(profile):
    with open(profile, 'r') as f:
        return parse_config(

def upload_package(url, token, path_name, skip_duplicate):
    headers = {
        'Authorization': f'token {token}'
    with open(path_name, 'rb') as f:
        response = requests.put(url, headers=headers,
        if response.ok or (response.status_code == 409 and skip_duplicate):
            print(f'{path_name}: {url}')
            raise SystemExit(f'Could not upload {path_name}: {response.reason} ({response.status_code})')

def main():
    parser = argparse.ArgumentParser(description='Upload packages to Gitea/Forgejo.')
    parser.add_argument('--glob', help='The file glob pattern to upload', required=True)
    parser.add_argument('--section', help='The config file section name', required=True)
    parser.add_argument('--postfix', help='An optional URL postfix', default='')
    parser.add_argument('--skip-duplicate', help='Succeed when uploading a duplicate package', action='store_true')
    args = parser.parse_args()
    path_names = glob.glob(args.glob, recursive=True)
    config = load_config(os.path.expanduser('~/.config/forgejo-push.conf'))[args.section]
    for path_name in path_names:
        url = config.url + args.postfix.replace('{file_name}', os.path.basename(path_name))
        upload_package(url, config.token, path_name, args.skip_duplicate)

if __name__ == '__main__':

Published: 2024-02-16