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 +192 -0
- gh_nfpm-0.1.0/README.md +164 -0
- gh_nfpm-0.1.0/pyproject.toml +59 -0
- gh_nfpm-0.1.0/src/gh_nfpm/__init__.py +0 -0
- gh_nfpm-0.1.0/src/gh_nfpm/archive.py +76 -0
- gh_nfpm-0.1.0/src/gh_nfpm/cli.py +159 -0
- gh_nfpm-0.1.0/src/gh_nfpm/config.py +134 -0
- gh_nfpm-0.1.0/src/gh_nfpm/nfpm.py +53 -0
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.
|
gh_nfpm-0.1.0/README.md
ADDED
|
@@ -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
|