gh-nfpm 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gh_nfpm-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.3
2
+ Name: gh-nfpm
3
+ Version: 0.1.0
4
+ Summary: Package GitHub releases using nFPM
5
+ Author: Fredrik Larsson
6
+ Author-email: Fredrik Larsson <pypi@fredriklarsson.dev>
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Classifier: Programming Language :: Python :: 3.13
9
+ Classifier: Programming Language :: Python :: 3.14
10
+ Classifier: Environment :: Console
11
+ Classifier: Framework :: Pydantic :: 2
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Topic :: System :: Archiving :: Packaging
16
+ Classifier: Typing :: Typed
17
+ Classifier: Intended Audience :: System Administrators
18
+ Requires-Dist: githubkit>=0.15.5,<0.16
19
+ Requires-Dist: httpx>=0.28.1
20
+ Requires-Dist: jsonschema>=4.26.0
21
+ Requires-Dist: pydantic>2,<3
22
+ Requires-Dist: pydantic-settings[yaml]>=2.14.1
23
+ Requires-Dist: pyyaml>=6.0.3
24
+ Requires-Dist: nfpm>=2.46.3 ; extra == 'nfpm'
25
+ Requires-Python: >=3.12
26
+ Provides-Extra: nfpm
27
+ Description-Content-Type: text/markdown
28
+
29
+ # gh-nfpm
30
+
31
+ Easily package GitHub releases using [nFPM](https://nfpm.goreleaser.com/).
32
+
33
+ Currently only supports building the latest GitHub release.
34
+
35
+ tl;dr You specify a GitHub repository, a list of release assets, and the nFPM
36
+ packaging configuration. gh-nfpm will download the matching assets from the
37
+ latest GitHub release in the repository and build an nFPM package for each
38
+ asset. You can build multiple package types for each asset.
39
+
40
+ ## Configuration
41
+
42
+ The configuration is stored inside a YAML file called `gh-nfpm.yaml`.
43
+
44
+ ### Repository and assets
45
+
46
+ The `repository` is specified with a string `organization/repository`, for
47
+ example this repository would be `nossralf/gh-nfpm`.
48
+
49
+ Asset matching supports literal matches or regular expressions. Currently only
50
+ `tar.gz` and `zip` assets are supported as package sources.
51
+
52
+ The shorthand format is literal matching, so:
53
+
54
+ ```yaml
55
+ assets:
56
+ - release.zip
57
+ - release.tar.gz
58
+ ```
59
+
60
+ would match release assets named exactly `release.zip` and `release.tar.gz`.
61
+
62
+ Regular expressions can be used for cases where the release asset contains a
63
+ version number:
64
+
65
+ ```yaml
66
+ assets:
67
+ - match:
68
+ kind: regex
69
+ pattern: ".*-aarch64-unknown-linux-musl.tar.gz$"
70
+ ```
71
+
72
+ This would match `my-tool-3.14.15-aarch64-unknown-linux-musl.tar.gz`.
73
+
74
+ If the architecture cannot be deduced from the asset name, it can be specified
75
+ as part of the asset configuration, for example with a literal match (which
76
+ needs to use the verbose format when specifying an architecture):
77
+
78
+ ```yaml
79
+ assets:
80
+ - arch: all
81
+ match:
82
+ kind: literal
83
+ pattern: architecture-independent-release.tar.gz
84
+ ```
85
+
86
+ gh-nfpm will verify the digest of downloaded assets if one is present in the
87
+ GitHub API response. It will abort if a digest doesn't match.
88
+
89
+ ### Packagers
90
+
91
+ The `packagers` list specifies which nFPM packagers should be run for each
92
+ asset. The supported values are `apk`, `archlinux`, `deb`, `ipk`, `msix`,
93
+ `rpm`, and `srpm`.
94
+
95
+ ### Release cooldown
96
+
97
+ Release cooldown is supported by setting `cooldown`, either as an integer
98
+ representing seconds, or as an [ISO 8601
99
+ duration](https://en.wikipedia.org/wiki/ISO_8601#Durations), for example `P1W`
100
+ for one week, `P2D` for 2 days, or `PT12H` for 12 hours. The last _update_ of
101
+ the GitHub release is used when evaluating the cooldown.
102
+
103
+ ### Other configuration options
104
+
105
+ - `token` sets the GitHub token used to interact with GitHub. By default,
106
+ unauthenticated access is used. It can also be set via the environment
107
+ variable `GHNFPM_TOKEN` to avoid hard-coding credentials in the configuration
108
+ file.
109
+ - `nfpm_executable` can be set to specify the path to nFPM. By default gh-nfpm
110
+ will assume that an `npfm` binary can be found via `$PATH`.
111
+
112
+ ## nFPM configuration
113
+
114
+ The nFPM configuration is stored under a top-level key `nfpm` in the
115
+ `gh-nfpm.yaml` configuration file. The complete nFPM configuration schema is
116
+ supported and the content is validated with the nFPM JSON Schema.
117
+
118
+ The `version` and `arch` fields can be left out of the nFPM configuration and
119
+ will then be filled in based on the release name and architecture from the
120
+ release assets. If you do specify the version or the architecture, the value
121
+ you specify will always be used when building the package.
122
+
123
+ ### Source paths
124
+
125
+ gh-nfpm will unpack the release assets by stripping off any leading directories
126
+ that don't contain files inside the asset, to avoid situations where e.g.
127
+ directories with version numbers would make static packaging configuration
128
+ impossible.
129
+
130
+ This means that if a release asset contains this directory structure:
131
+
132
+ ```
133
+ .
134
+ └── release
135
+ └── 0.13
136
+ ├── completions
137
+ │   └── tool.1
138
+ └── tool
139
+ ```
140
+
141
+ gh-nfpm will strip the leading `release/0.13` and unpack the asset like this:
142
+
143
+ ```
144
+ .
145
+ ├── completions
146
+ │   └── tool.1
147
+ └── tool
148
+ ```
149
+
150
+ This enables specifying `tool` as the source path in nFPM's content
151
+ specification, instead of `release/0.13/tool`.
152
+
153
+ ## Example
154
+
155
+ This is how you would build Debian packages (both x86-64 and AArch64) for
156
+ [uv](https://docs.astral.sh/uv/) with a 2 day release cooldown.
157
+
158
+ ```yaml
159
+ repository: astral-sh/uv
160
+ cooldown: P2D
161
+ assets:
162
+ - uv-x86_64-unknown-linux-musl.tar.gz
163
+ - uv-aarch64-unknown-linux-musl.tar.gz
164
+ packagers:
165
+ - deb
166
+
167
+ nfpm:
168
+ name: uv
169
+ platform: linux
170
+ section: python
171
+ description: |-
172
+ An extremely fast Python package and project manager, written in Rust.
173
+ homepage: https://docs.astral.sh/uv/
174
+ maintainer: Mackenzie Maintainer <mackenzie@example.com>
175
+ license: MIT or Apache-2.0
176
+
177
+ contents:
178
+ - src: uv
179
+ dst: /usr/bin/uv
180
+ - src: uvx
181
+ dst: /usr/bin/uvx
182
+ ```
183
+
184
+ ## Usage
185
+
186
+ Create a gh-nfpm.yaml file, then run `uvx gh-nfpm`. The built packages will be
187
+ placed in the current directory.
188
+
189
+ If you don't have nFPM installed, you can run `uvx 'gh-nfpm[npfm]'` and nFPM
190
+ will be installed via the `nfpm`[Python
191
+ package](https://pypi.org/project/nfpm/). Be aware that the `npfm` PyPI package
192
+ is **not** provided by the nFPM team.
@@ -0,0 +1,164 @@
1
+ # gh-nfpm
2
+
3
+ Easily package GitHub releases using [nFPM](https://nfpm.goreleaser.com/).
4
+
5
+ Currently only supports building the latest GitHub release.
6
+
7
+ tl;dr You specify a GitHub repository, a list of release assets, and the nFPM
8
+ packaging configuration. gh-nfpm will download the matching assets from the
9
+ latest GitHub release in the repository and build an nFPM package for each
10
+ asset. You can build multiple package types for each asset.
11
+
12
+ ## Configuration
13
+
14
+ The configuration is stored inside a YAML file called `gh-nfpm.yaml`.
15
+
16
+ ### Repository and assets
17
+
18
+ The `repository` is specified with a string `organization/repository`, for
19
+ example this repository would be `nossralf/gh-nfpm`.
20
+
21
+ Asset matching supports literal matches or regular expressions. Currently only
22
+ `tar.gz` and `zip` assets are supported as package sources.
23
+
24
+ The shorthand format is literal matching, so:
25
+
26
+ ```yaml
27
+ assets:
28
+ - release.zip
29
+ - release.tar.gz
30
+ ```
31
+
32
+ would match release assets named exactly `release.zip` and `release.tar.gz`.
33
+
34
+ Regular expressions can be used for cases where the release asset contains a
35
+ version number:
36
+
37
+ ```yaml
38
+ assets:
39
+ - match:
40
+ kind: regex
41
+ pattern: ".*-aarch64-unknown-linux-musl.tar.gz$"
42
+ ```
43
+
44
+ This would match `my-tool-3.14.15-aarch64-unknown-linux-musl.tar.gz`.
45
+
46
+ If the architecture cannot be deduced from the asset name, it can be specified
47
+ as part of the asset configuration, for example with a literal match (which
48
+ needs to use the verbose format when specifying an architecture):
49
+
50
+ ```yaml
51
+ assets:
52
+ - arch: all
53
+ match:
54
+ kind: literal
55
+ pattern: architecture-independent-release.tar.gz
56
+ ```
57
+
58
+ gh-nfpm will verify the digest of downloaded assets if one is present in the
59
+ GitHub API response. It will abort if a digest doesn't match.
60
+
61
+ ### Packagers
62
+
63
+ The `packagers` list specifies which nFPM packagers should be run for each
64
+ asset. The supported values are `apk`, `archlinux`, `deb`, `ipk`, `msix`,
65
+ `rpm`, and `srpm`.
66
+
67
+ ### Release cooldown
68
+
69
+ Release cooldown is supported by setting `cooldown`, either as an integer
70
+ representing seconds, or as an [ISO 8601
71
+ duration](https://en.wikipedia.org/wiki/ISO_8601#Durations), for example `P1W`
72
+ for one week, `P2D` for 2 days, or `PT12H` for 12 hours. The last _update_ of
73
+ the GitHub release is used when evaluating the cooldown.
74
+
75
+ ### Other configuration options
76
+
77
+ - `token` sets the GitHub token used to interact with GitHub. By default,
78
+ unauthenticated access is used. It can also be set via the environment
79
+ variable `GHNFPM_TOKEN` to avoid hard-coding credentials in the configuration
80
+ file.
81
+ - `nfpm_executable` can be set to specify the path to nFPM. By default gh-nfpm
82
+ will assume that an `npfm` binary can be found via `$PATH`.
83
+
84
+ ## nFPM configuration
85
+
86
+ The nFPM configuration is stored under a top-level key `nfpm` in the
87
+ `gh-nfpm.yaml` configuration file. The complete nFPM configuration schema is
88
+ supported and the content is validated with the nFPM JSON Schema.
89
+
90
+ The `version` and `arch` fields can be left out of the nFPM configuration and
91
+ will then be filled in based on the release name and architecture from the
92
+ release assets. If you do specify the version or the architecture, the value
93
+ you specify will always be used when building the package.
94
+
95
+ ### Source paths
96
+
97
+ gh-nfpm will unpack the release assets by stripping off any leading directories
98
+ that don't contain files inside the asset, to avoid situations where e.g.
99
+ directories with version numbers would make static packaging configuration
100
+ impossible.
101
+
102
+ This means that if a release asset contains this directory structure:
103
+
104
+ ```
105
+ .
106
+ └── release
107
+ └── 0.13
108
+ ├── completions
109
+ │   └── tool.1
110
+ └── tool
111
+ ```
112
+
113
+ gh-nfpm will strip the leading `release/0.13` and unpack the asset like this:
114
+
115
+ ```
116
+ .
117
+ ├── completions
118
+ │   └── tool.1
119
+ └── tool
120
+ ```
121
+
122
+ This enables specifying `tool` as the source path in nFPM's content
123
+ specification, instead of `release/0.13/tool`.
124
+
125
+ ## Example
126
+
127
+ This is how you would build Debian packages (both x86-64 and AArch64) for
128
+ [uv](https://docs.astral.sh/uv/) with a 2 day release cooldown.
129
+
130
+ ```yaml
131
+ repository: astral-sh/uv
132
+ cooldown: P2D
133
+ assets:
134
+ - uv-x86_64-unknown-linux-musl.tar.gz
135
+ - uv-aarch64-unknown-linux-musl.tar.gz
136
+ packagers:
137
+ - deb
138
+
139
+ nfpm:
140
+ name: uv
141
+ platform: linux
142
+ section: python
143
+ description: |-
144
+ An extremely fast Python package and project manager, written in Rust.
145
+ homepage: https://docs.astral.sh/uv/
146
+ maintainer: Mackenzie Maintainer <mackenzie@example.com>
147
+ license: MIT or Apache-2.0
148
+
149
+ contents:
150
+ - src: uv
151
+ dst: /usr/bin/uv
152
+ - src: uvx
153
+ dst: /usr/bin/uvx
154
+ ```
155
+
156
+ ## Usage
157
+
158
+ Create a gh-nfpm.yaml file, then run `uvx gh-nfpm`. The built packages will be
159
+ placed in the current directory.
160
+
161
+ If you don't have nFPM installed, you can run `uvx 'gh-nfpm[npfm]'` and nFPM
162
+ will be installed via the `nfpm`[Python
163
+ package](https://pypi.org/project/nfpm/). Be aware that the `npfm` PyPI package
164
+ is **not** provided by the nFPM team.
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "gh-nfpm"
3
+ version = "0.1.0"
4
+ description = "Package GitHub releases using nFPM"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Fredrik Larsson", email = "pypi@fredriklarsson.dev" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3.12",
12
+ "Programming Language :: Python :: 3.13",
13
+ "Programming Language :: Python :: 3.14",
14
+ "Environment :: Console",
15
+ "Framework :: Pydantic :: 2",
16
+ "Operating System :: MacOS",
17
+ "Operating System :: Microsoft :: Windows",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Topic :: System :: Archiving :: Packaging",
20
+ "Typing :: Typed",
21
+ "Intended Audience :: System Administrators",
22
+ ]
23
+
24
+ dependencies = [
25
+ "githubkit>=0.15.5,<0.16",
26
+ "httpx>=0.28.1",
27
+ "jsonschema>=4.26.0",
28
+ "pydantic>2,<3",
29
+ "pydantic-settings[yaml]>=2.14.1",
30
+ "pyyaml>=6.0.3",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ nfpm = [
35
+ "nfpm>=2.46.3",
36
+ ]
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=9.1.0",
41
+ "pytest-cov>=7.1.0",
42
+ "pytest-mock>=3.15.1",
43
+ ]
44
+
45
+ [project.scripts]
46
+ gh-nfpm = "gh_nfpm.cli:main"
47
+
48
+ [build-system]
49
+ requires = ["uv_build>=0.11.19,<0.12.0"]
50
+ build-backend = "uv_build"
51
+
52
+ [tool.bumpversion]
53
+ allow_dirty = false
54
+ commit = true
55
+ message = "Version {new_version}"
56
+ sign_tags = false
57
+ tag = true
58
+ tag_message = "Version {new_version}"
59
+ tag_name = "v{new_version}"
File without changes
@@ -0,0 +1,76 @@
1
+ import os
2
+ import stat
3
+ import tarfile
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path, PurePosixPath
6
+ from typing import Any
7
+ from zipfile import ZipFile, ZipInfo
8
+
9
+
10
+ class Archive(ABC):
11
+ @abstractmethod
12
+ def files(self) -> list[str]: ...
13
+
14
+ @abstractmethod
15
+ def _open_member(self, name: str) -> Any: ...
16
+
17
+ @abstractmethod
18
+ def close(self): ...
19
+
20
+ def _extract_member(self, name: str, dest: Path):
21
+ with self._open_member(name) as src:
22
+ dest.write_bytes(src.read())
23
+
24
+ def extract(self, dest: Path):
25
+ files = self.files()
26
+ if not files:
27
+ return
28
+ paths = [PurePosixPath(f) for f in files]
29
+ if len(paths) == 1:
30
+ prefix = paths[0].parent
31
+ else:
32
+ prefix = PurePosixPath(os.path.commonpath(paths))
33
+ for f, p in zip(files, paths):
34
+ target_file = dest.joinpath(p.relative_to(prefix) if prefix.parts else p)
35
+
36
+ if not target_file.resolve().is_relative_to(dest.resolve()):
37
+ raise ValueError(
38
+ f"Archive member {f} would be written outside destination directory."
39
+ )
40
+
41
+ target_file.parent.mkdir(parents=True, exist_ok=True)
42
+ self._extract_member(f, dest=target_file)
43
+
44
+ def __enter__(self):
45
+ return self
46
+
47
+ def __exit__(self):
48
+ self.close()
49
+
50
+
51
+ class ZipArchive(Archive):
52
+ def __init__(self, zip_file: Path):
53
+ self._zf = ZipFile(zip_file)
54
+
55
+ def files(self) -> list[str]:
56
+ return [i.filename for i in self._zf.infolist() if not i.is_dir()]
57
+
58
+ def _open_member(self, name: str):
59
+ return self._zf.open(name)
60
+
61
+ def close(self):
62
+ self._zf.close()
63
+
64
+
65
+ class TarArchive(Archive):
66
+ def __init__(self, tar_file: Path):
67
+ self._tar = tarfile.open(tar_file)
68
+
69
+ def files(self) -> list[str]:
70
+ return [m.name for m in self._tar.getmembers() if m.isfile()]
71
+
72
+ def _open_member(self, name: str):
73
+ return self._tar.extractfile(name)
74
+
75
+ def close(self):
76
+ self._tar.close()
@@ -0,0 +1,159 @@
1
+ import copy
2
+ import hashlib
3
+ import itertools
4
+ import shutil
5
+ import sys
6
+ import tarfile
7
+ import zipfile
8
+ from collections.abc import Iterable
9
+ from contextlib import contextmanager
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from tempfile import TemporaryDirectory
13
+ from typing import Generator, Protocol, Self
14
+
15
+ import jsonschema
16
+ import yaml
17
+ from githubkit import GitHub, UnauthAuthStrategy
18
+ from githubkit.versions.latest.models import Release
19
+ from httpx import Client
20
+
21
+ from .archive import Archive, TarArchive, ZipArchive
22
+ from .config import Arch, Config, ConfigAsset, PackageSource
23
+ from .nfpm import Nfpm
24
+
25
+
26
+ class NullHasher:
27
+ def update(self, _: bytes) -> None:
28
+ pass
29
+
30
+ def digest(self) -> bytes:
31
+ raise NotImplementedError()
32
+
33
+ def hexdigest(self) -> str:
34
+ raise NotImplementedError()
35
+
36
+ def copy(self) -> Self:
37
+ return type(self)()
38
+
39
+
40
+ @contextmanager
41
+ def temporary_directory(
42
+ suffix=None, prefix=None, dir=None, ignore_cleanup_errors=False, *, delete=True
43
+ ) -> Generator[Path, None, None]:
44
+ with TemporaryDirectory(
45
+ suffix, prefix, dir, ignore_cleanup_errors, delete=delete
46
+ ) as dir:
47
+ yield Path(dir)
48
+
49
+
50
+ def get_package_sources(
51
+ requested_assets: Iterable[ConfigAsset], release: Release
52
+ ) -> Iterable[PackageSource]:
53
+ package_sources = []
54
+ for release_asset, requested_asset in itertools.product(
55
+ release.assets, requested_assets
56
+ ):
57
+ if requested_asset.match.matches(release_asset.name):
58
+ if requested_asset.arch is None:
59
+ arch = Arch.guess_from_filename(release_asset.name)
60
+ else:
61
+ arch = requested_asset.arch
62
+ package_sources.append(PackageSource(asset=release_asset, arch=arch))
63
+
64
+ return package_sources
65
+
66
+
67
+ def download_package_source(
68
+ client: Client, source: PackageSource, dest: Path
69
+ ) -> Archive:
70
+ if source.asset.digest is not None:
71
+ alg, *_ = source.asset.digest.partition(":")
72
+ hasher = hashlib.new(alg)
73
+ else:
74
+ hasher = NullHasher()
75
+
76
+ output_file = dest.joinpath(source.arch).with_suffix(".tar.gz")
77
+
78
+ with (
79
+ client.stream(
80
+ "GET", source.asset.browser_download_url, follow_redirects=True
81
+ ) as stream,
82
+ output_file.open("wb") as out,
83
+ ):
84
+ for chunk in stream.iter_bytes():
85
+ out.write(chunk)
86
+ hasher.update(chunk)
87
+
88
+ if source.asset.digest is not None:
89
+ *_, expected_digest = source.asset.digest.partition(":")
90
+ actual_digest = hasher.hexdigest()
91
+ if actual_digest != expected_digest:
92
+ output_file.unlink()
93
+ raise ValueError(
94
+ f"Digest mismatch: expected {expected_digest}, got {actual_digest}"
95
+ )
96
+
97
+ if zipfile.is_zipfile(output_file):
98
+ return ZipArchive(output_file)
99
+ elif tarfile.is_tarfile(output_file):
100
+ return TarArchive(output_file)
101
+ raise ValueError(f"Unsupported file type for '{source.asset.name}'")
102
+
103
+
104
+ def bail(message: str):
105
+ print(f"Error: {message}", file=sys.stderr)
106
+ sys.exit(1)
107
+
108
+
109
+ def main() -> None:
110
+ config = Config() # type: ignore[call-arg]
111
+ project, _, repository = config.repository.partition("/")
112
+
113
+ nfpm = Nfpm(executable=config.nfpm_executable)
114
+
115
+ if not nfpm.available:
116
+ bail("Can't find nFPM")
117
+
118
+ print(f"Using nFPM version {nfpm.version}")
119
+
120
+ gh = GitHub(
121
+ config.token.get_secret_value()
122
+ if config.token is not None
123
+ else UnauthAuthStrategy()
124
+ )
125
+
126
+ latest_release: Release = gh.rest.repos.get_latest_release(
127
+ project, repository
128
+ ).parsed_data
129
+
130
+ if config.cooldown is not None:
131
+ release_changed = (
132
+ latest_release.updated_at
133
+ if latest_release.updated_at
134
+ else latest_release.created_at
135
+ )
136
+ now = datetime.now(timezone.utc)
137
+ if (now - config.cooldown) < release_changed:
138
+ bail("Release newer than configured cooldown allows.")
139
+
140
+ sources = get_package_sources(config.assets, latest_release)
141
+
142
+ with temporary_directory() as downloads:
143
+ for source in sources:
144
+ asset = download_package_source(Client(), source, dest=downloads)
145
+
146
+ nfpm_config = copy.deepcopy(config.nfpm)
147
+ nfpm_config["version"] = nfpm_config.get("version", latest_release.name)
148
+ nfpm_config["arch"] = nfpm_config.get("arch", str(source.arch))
149
+
150
+ jsonschema.validate(nfpm_config, nfpm.jsonschema)
151
+
152
+ with temporary_directory() as build_dir:
153
+ asset.extract(build_dir)
154
+ build_dir.joinpath("nfpm.yaml").write_text(
155
+ yaml.dump(nfpm_config, sort_keys=False)
156
+ )
157
+ for packager in config.packagers:
158
+ package = nfpm.package(packager, build_dir)
159
+ shutil.move(package, Path.cwd().joinpath(package.name))
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import timedelta
6
+ from enum import StrEnum
7
+ from typing import Annotated, Any, Literal, Self, cast
8
+
9
+ from githubkit.versions.latest.models import ReleaseAsset
10
+ from pydantic import (
11
+ AfterValidator,
12
+ BaseModel,
13
+ Field,
14
+ ModelWrapValidatorHandler,
15
+ SecretStr,
16
+ StringConstraints,
17
+ model_validator,
18
+ )
19
+ from pydantic_settings import (
20
+ BaseSettings,
21
+ PydanticBaseSettingsSource,
22
+ SettingsConfigDict,
23
+ YamlConfigSettingsSource,
24
+ )
25
+
26
+
27
+ class Packager(StrEnum):
28
+ APK = "apk"
29
+ ARCHLINUX = "archlinux"
30
+ DEB = "deb"
31
+ IPK = "ipk"
32
+ MSIX = "msix"
33
+ RPM = "rpm"
34
+ SRPM = "srpm"
35
+
36
+ @property
37
+ def extension(self) -> str:
38
+ match self:
39
+ case Packager.ARCHLINUX:
40
+ return "pkg.tar.zst"
41
+ case Packager.SRPM:
42
+ return "src.rpm"
43
+ case _:
44
+ return str(self)
45
+
46
+
47
+ class Arch(StrEnum):
48
+ ARM64 = "arm64"
49
+ AMD64 = "amd64"
50
+ ALL = "all"
51
+
52
+ @classmethod
53
+ def guess_from_filename(cls, filename: str) -> Self:
54
+ if any(a in filename for a in ("amd64", "x86_64")):
55
+ return cast(Self, cls.AMD64)
56
+ elif any(a in filename for a in ("arm64", "aarch64")):
57
+ return cast(Self, cls.ARM64)
58
+ else:
59
+ raise ValueError(f"Can't guess architecture from filename '{filename}'")
60
+
61
+
62
+ @dataclass
63
+ class PackageSource:
64
+ asset: ReleaseAsset
65
+ arch: Arch
66
+
67
+
68
+ StrippedStr = Annotated[str, StringConstraints(strip_whitespace=True)]
69
+
70
+
71
+ class LiteralMatch(BaseModel):
72
+ kind: Literal["literal"] = "literal"
73
+ pattern: str
74
+
75
+ def matches(self, string: str) -> bool:
76
+ return string == self.pattern
77
+
78
+
79
+ class RegexMatch(BaseModel):
80
+ kind: Literal["regex"] = "regex"
81
+ pattern: str
82
+
83
+ def matches(self, string: str) -> bool:
84
+ return re.match(self.pattern, string) is not None
85
+
86
+
87
+ Match = Annotated[
88
+ LiteralMatch | RegexMatch,
89
+ Field(discriminator="kind"),
90
+ ]
91
+
92
+
93
+ class ConfigAsset(BaseModel):
94
+ match: Match
95
+ arch: Arch | None = None
96
+
97
+ @model_validator(mode="wrap")
98
+ @classmethod
99
+ def _coerce_str(cls, value: Any, handler: ModelWrapValidatorHandler[Self]) -> Self:
100
+ if isinstance(value, str):
101
+ return cls(match=LiteralMatch(pattern=value))
102
+ return handler(value)
103
+
104
+
105
+ Cooldown = Annotated[timedelta, AfterValidator(lambda v: abs(v))]
106
+
107
+
108
+ class Config(BaseSettings):
109
+ model_config = SettingsConfigDict(
110
+ yaml_file="gh-nfpm.yaml", env_prefix="GHNFPM_", env_nested_delimiter="_"
111
+ )
112
+
113
+ repository: StrippedStr
114
+ assets: list[ConfigAsset]
115
+ nfpm: dict[Any, Any]
116
+ packagers: set[Packager]
117
+ token: SecretStr | None = None
118
+ cooldown: Cooldown | None = None
119
+ nfpm_executable: str = "nfpm"
120
+
121
+ @classmethod
122
+ def settings_customise_sources(
123
+ cls,
124
+ settings_cls: type[BaseSettings],
125
+ init_settings: PydanticBaseSettingsSource,
126
+ env_settings: PydanticBaseSettingsSource,
127
+ dotenv_settings: PydanticBaseSettingsSource,
128
+ file_secret_settings: PydanticBaseSettingsSource,
129
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
130
+ return (
131
+ init_settings,
132
+ env_settings,
133
+ YamlConfigSettingsSource(settings_cls),
134
+ )
@@ -0,0 +1,53 @@
1
+ import json
2
+ import subprocess
3
+ from dataclasses import dataclass
4
+ from functools import cached_property
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .config import Packager
9
+
10
+
11
+ @dataclass
12
+ class Nfpm:
13
+ executable: Path | str = "nfpm"
14
+
15
+ @cached_property
16
+ def available(self) -> bool:
17
+ try:
18
+ subprocess.run([self.executable, "--version"], capture_output=True)
19
+ except FileNotFoundError:
20
+ return False
21
+ return True
22
+
23
+ @cached_property
24
+ def version(self) -> str:
25
+ nfpm = subprocess.run([self.executable, "--version"], capture_output=True)
26
+ stdout = nfpm.stdout.decode("utf-8").splitlines()
27
+ for line in stdout:
28
+ if line.startswith("GitVersion:"):
29
+ _, _, version = line.partition(":")
30
+ return version.strip()
31
+ return "Unknown"
32
+
33
+ @cached_property
34
+ def jsonschema(self) -> dict[Any, Any]:
35
+ nfpm = subprocess.run([self.executable, "jsonschema"], capture_output=True)
36
+ jsonschema = nfpm.stdout.decode("utf-8")
37
+ return json.loads(jsonschema)
38
+
39
+ def package(self, packager: Packager, dir: Path) -> Path:
40
+ nfpm = subprocess.run(
41
+ [self.executable, "package", "-p", str(packager)],
42
+ cwd=dir,
43
+ capture_output=True,
44
+ )
45
+ stdout = nfpm.stdout.decode("utf-8").splitlines()
46
+ for line in stdout:
47
+ if line.startswith("created package:"):
48
+ *_, package = line.partition(":")
49
+ return dir.joinpath(package.strip())
50
+ # Fall back to looking for the package based on the file extension if
51
+ # we are unable to read the name from nFPM's stdout.
52
+ package, *_ = list(dir.glob(f"*.{packager.extension}"))
53
+ return package