+19
-24
.forgejo/workflows/ci.yaml
+19
-24
.forgejo/workflows/ci.yaml
···
7
7
8
8
jobs:
9
9
check:
10
-
runs-on: codeberg-small-lazy
10
+
runs-on: debian-trixie
11
11
container:
12
-
image: docker.io/library/node:24-trixie-slim@sha256:fcdfd7bcd8f641c8c76a8950343c73912d68ba341e8dd1074e663b784d3e76f4
12
+
image: docker.io/library/node:24-trixie-slim@sha256:b05474903f463ce4064c09986525e6588c3e66c51b69be9c93a39fb359f883ce
13
13
steps:
14
14
- name: Check out source code
15
-
uses: https://code.forgejo.org/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
15
+
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
16
16
- name: Set up toolchain
17
17
uses: https://code.forgejo.org/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
18
18
with:
···
34
34
# IMPORTANT: This workflow step will not work without the Releases unit enabled!
35
35
if: ${{ forge.ref == 'refs/heads/main' || startsWith(forge.event.ref, 'refs/tags/v') }}
36
36
needs: [check]
37
-
runs-on: codeberg-small-lazy
37
+
runs-on: debian-trixie
38
38
container:
39
-
image: docker.io/library/node:24-trixie-slim@sha256:fcdfd7bcd8f641c8c76a8950343c73912d68ba341e8dd1074e663b784d3e76f4
39
+
image: docker.io/library/node:24-trixie-slim@sha256:b05474903f463ce4064c09986525e6588c3e66c51b69be9c93a39fb359f883ce
40
40
steps:
41
41
- name: Check out source code
42
-
uses: https://code.forgejo.org/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
42
+
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
43
43
- name: Set up toolchain
44
44
uses: https://code.forgejo.org/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
45
45
with:
···
70
70
package:
71
71
if: ${{ forge.ref == 'refs/heads/main' || startsWith(forge.event.ref, 'refs/tags/v') }}
72
72
needs: [check]
73
-
runs-on: codeberg-small-lazy
73
+
runs-on: debian-trixie
74
74
container:
75
-
image: docker.io/library/node:24-trixie-slim@sha256:fcdfd7bcd8f641c8c76a8950343c73912d68ba341e8dd1074e663b784d3e76f4
75
+
image: docker.io/library/node:24-trixie-slim@sha256:b05474903f463ce4064c09986525e6588c3e66c51b69be9c93a39fb359f883ce
76
76
steps:
77
77
- name: Install dependencies
78
78
run: |
79
79
apt-get -y update
80
-
apt-get -y install buildah ca-certificates
80
+
apt-get -y install ca-certificates buildah qemu-user-binfmt
81
81
- name: Check out source code
82
-
uses: https://code.forgejo.org/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
82
+
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
83
83
- name: Build container
84
84
run: |
85
85
printf '[storage]\ndriver="vfs"\nrunroot="/run/containers/storage"\ngraphroot="/var/lib/containers/storage"\n' | tee /etc/containers/storage.conf
86
-
buildah build --arch=amd64 --tag=container:${VER}-amd64 .
87
-
buildah build --arch=arm64 --tag=container:${VER}-arm64 .
88
-
buildah manifest create container:${VER} \
89
-
container:${VER}-amd64 \
90
-
container:${VER}-arm64
86
+
buildah build --arch=amd64 --tag=container:amd64
87
+
buildah build --arch=arm64 --tag=container:arm64
88
+
buildah manifest create container container:amd64 container:arm64
91
89
env:
92
90
BUILDAH_ISOLATION: chroot
93
-
VER: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }}
94
91
- if: ${{ forge.repository == 'git-pages/git-pages-cli' }}
95
92
name: Push container to Codeberg
96
93
run: |
97
-
buildah login --authfile=/tmp/authfile-${FORGE}.json \
94
+
buildah login --authfile=/tmp/authfile.json \
98
95
-u ${{ vars.PACKAGES_USER }} -p ${{ secrets.PACKAGES_TOKEN }} ${FORGE}
99
-
buildah manifest push --authfile=/tmp/authfile-${FORGE}.json \
100
-
--all container:${VER} "docker://${FORGE}/${{ forge.repository }}:${VER/v/}"
96
+
buildah manifest push --authfile=/tmp/authfile.json \
97
+
--all container "docker://${FORGE}/${{ forge.repository }}:${VER/v/}"
101
98
env:
102
-
BUILDAH_ISOLATION: chroot
103
99
FORGE: codeberg.org
104
100
VER: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }}
105
101
- if: ${{ forge.repository == 'git-pages/git-pages-cli' }}
106
102
name: Push container to code.forgejo.org
107
103
run: |
108
-
buildah login --authfile=/tmp/authfile-${FORGE}.json \
104
+
buildah login --authfile=/tmp/authfile.json \
109
105
-u ${{ vars.PACKAGES_USER }} -p ${{ secrets.CFO_PACKAGES_TOKEN }} ${FORGE}
110
-
buildah manifest push --authfile=/tmp/authfile-${FORGE}.json \
111
-
--all container:${VER} "docker://${FORGE}/${{ forge.repository }}:${VER/v/}"
106
+
buildah manifest push --authfile=/tmp/authfile.json \
107
+
--all container "docker://${FORGE}/${{ forge.repository }}:${VER/v/}"
112
108
env:
113
-
BUILDAH_ISOLATION: chroot
114
109
FORGE: code.forgejo.org
115
110
VER: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }}
+1
-1
Dockerfile
+1
-1
Dockerfile
···
1
-
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.25-alpine@sha256:d3f0cf7723f3429e3f9ed846243970b20a2de7bae6a5b66fc5914e228d831bbb AS builder
1
+
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.25-alpine@sha256:ac09a5f469f307e5da71e766b0bd59c9c49ea460a528cc3e6686513d64a6f1fb AS builder
2
2
ARG TARGETOS TARGETARCH
3
3
RUN apk --no-cache add ca-certificates git
4
4
WORKDIR /build
+24
flake.lock
+24
flake.lock
···
18
18
"type": "github"
19
19
}
20
20
},
21
+
"gomod2nix": {
22
+
"inputs": {
23
+
"flake-utils": [
24
+
"flake-utils"
25
+
],
26
+
"nixpkgs": [
27
+
"nixpkgs"
28
+
]
29
+
},
30
+
"locked": {
31
+
"lastModified": 1763982521,
32
+
"narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=",
33
+
"owner": "nix-community",
34
+
"repo": "gomod2nix",
35
+
"rev": "02e63a239d6eabd595db56852535992c898eba72",
36
+
"type": "github"
37
+
},
38
+
"original": {
39
+
"owner": "nix-community",
40
+
"repo": "gomod2nix",
41
+
"type": "github"
42
+
}
43
+
},
21
44
"nix-filter": {
22
45
"locked": {
23
46
"lastModified": 1757882181,
···
52
75
"root": {
53
76
"inputs": {
54
77
"flake-utils": "flake-utils",
78
+
"gomod2nix": "gomod2nix",
55
79
"nix-filter": "nix-filter",
56
80
"nixpkgs": "nixpkgs"
57
81
}
+22
-4
flake.nix
+22
-4
flake.nix
···
3
3
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
4
4
flake-utils.url = "github:numtide/flake-utils";
5
5
nix-filter.url = "github:numtide/nix-filter";
6
+
7
+
gomod2nix = {
8
+
url = "github:nix-community/gomod2nix";
9
+
inputs.nixpkgs.follows = "nixpkgs";
10
+
inputs.flake-utils.follows = "flake-utils";
11
+
};
6
12
};
7
13
8
14
outputs =
···
11
17
nixpkgs,
12
18
flake-utils,
13
19
nix-filter,
14
-
}:
20
+
...
21
+
}@inputs:
15
22
flake-utils.lib.eachDefaultSystem (
16
23
system:
17
24
let
18
-
pkgs = nixpkgs.legacyPackages.${system};
25
+
pkgs = import nixpkgs {
26
+
inherit system;
19
27
20
-
git-pages-cli = pkgs.buildGo125Module {
28
+
overlays = [
29
+
inputs.gomod2nix.overlays.default
30
+
];
31
+
};
32
+
33
+
git-pages-cli = pkgs.buildGoApplication {
21
34
pname = "git-pages-cli";
22
35
version = "0";
23
36
···
41
54
"-s -w"
42
55
];
43
56
44
-
vendorHash = "sha256-5vjUhN3lCr41q91lOD7v0F9c6a8GJj7wBGnnzgFBhJU=";
57
+
go = pkgs.go_1_25;
58
+
modules = ./gomod2nix.toml;
45
59
};
46
60
in
47
61
{
···
50
64
devShells.default = pkgs.mkShell {
51
65
inputsFrom = [
52
66
git-pages-cli
67
+
];
68
+
69
+
packages = with pkgs; [
70
+
gomod2nix
53
71
];
54
72
};
55
73
+2
-2
go.mod
+2
-2
go.mod
+2
-2
go.sum
+2
-2
go.sum
···
1
1
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
2
2
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
3
-
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
4
-
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
3
+
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
4
+
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
5
5
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
6
6
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+12
gomod2nix.toml
+12
gomod2nix.toml
···
1
+
schema = 3
2
+
3
+
[mod]
4
+
[mod."github.com/google/uuid"]
5
+
version = "v1.6.0"
6
+
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
7
+
[mod."github.com/klauspost/compress"]
8
+
version = "v1.18.2"
9
+
hash = "sha256-mRa+6qEi5joqQao13ZFogmq67rOQzHCVbCCjKA+HKEc="
10
+
[mod."github.com/spf13/pflag"]
11
+
version = "v1.0.10"
12
+
hash = "sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU="
+111
-60
main.go
+111
-60
main.go
···
2
2
3
3
import (
4
4
"archive/tar"
5
+
"bufio"
5
6
"bytes"
7
+
"crypto"
6
8
"crypto/sha256"
9
+
"encoding/hex"
7
10
"errors"
8
11
"fmt"
9
12
"io"
···
12
15
"net/url"
13
16
"os"
14
17
"runtime/debug"
18
+
"strconv"
15
19
"strings"
16
20
17
21
"github.com/google/uuid"
···
39
43
var challengeFlag = pflag.Bool("challenge", false, "compute DNS challenge entry from password (output zone file record)")
40
44
var challengeBareFlag = pflag.Bool("challenge-bare", false, "compute DNS challenge entry from password (output bare TXT value)")
41
45
var uploadGitFlag = pflag.String("upload-git", "", "replace site with contents of specified git repository")
42
-
var uploadDirFlag = pflag.String("upload-dir", "", "replace site with contents of specified directory")
43
-
var deleteFlag = pflag.Bool("delete", false, "delete site")
46
+
var uploadDirFlag = pflag.String("upload-dir", "", "replace whole site or a path with contents of specified directory")
47
+
var deleteFlag = pflag.Bool("delete", false, "delete whole site or a path")
44
48
var debugManifestFlag = pflag.Bool("debug-manifest", false, "retrieve site manifest as ProtoJSON, for debugging")
45
49
var serverFlag = pflag.String("server", "", "hostname of server to connect to")
46
50
var pathFlag = pflag.String("path", "", "partially update site at specified path")
47
51
var parentsFlag = pflag.Bool("parents", false, "create parent directories of --path")
48
52
var atomicFlag = pflag.Bool("atomic", false, "require partial updates to be atomic")
53
+
var incrementalFlag = pflag.Bool("incremental", false, "make --upload-dir only upload changed files")
49
54
var verboseFlag = pflag.BoolP("verbose", "v", false, "display more information for debugging")
50
55
var versionFlag = pflag.BoolP("version", "V", false, "display version information")
51
56
···
75
80
return operations == 1
76
81
}
77
82
83
+
func gitBlobSHA256(data []byte) string {
84
+
h := crypto.SHA256.New()
85
+
h.Write([]byte("blob "))
86
+
h.Write([]byte(strconv.FormatInt(int64(len(data)), 10)))
87
+
h.Write([]byte{0})
88
+
h.Write(data)
89
+
return hex.EncodeToString(h.Sum(nil))
90
+
}
91
+
78
92
func displayFS(root fs.FS, prefix string) error {
79
93
return fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
80
94
if err != nil {
···
94
108
})
95
109
}
96
110
97
-
func archiveFS(writer io.Writer, root fs.FS, prefix string) (err error) {
111
+
// It doesn't make sense to use incremental updates for very small files since the cost of
112
+
// repeating a request to fill in a missing blob is likely to be higher than any savings gained.
113
+
const incrementalSizeThreshold = 256
114
+
115
+
func archiveFS(writer io.Writer, root fs.FS, prefix string, needBlobs []string) (err error) {
116
+
requestedSet := make(map[string]struct{})
117
+
for _, hash := range needBlobs {
118
+
requestedSet[hash] = struct{}{}
119
+
}
98
120
zstdWriter, _ := zstd.NewWriter(writer)
99
121
tarWriter := tar.NewWriter(zstdWriter)
100
122
if err = fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
101
123
if err != nil {
102
124
return err
103
125
}
104
-
fileInfo, err := entry.Info()
105
-
if err != nil {
106
-
return err
107
-
}
108
-
var tarName string
126
+
header := &tar.Header{}
127
+
data := []byte{}
109
128
if prefix == "" && name == "." {
110
129
return nil
111
130
} else if name == "." {
112
-
tarName = prefix
131
+
header.Name = prefix
113
132
} else {
114
-
tarName = prefix + name
133
+
header.Name = prefix + name
115
134
}
116
-
var file io.ReadCloser
117
-
var linkTarget string
118
135
switch {
119
136
case entry.Type().IsDir():
120
-
name += "/"
137
+
header.Typeflag = tar.TypeDir
138
+
header.Name += "/"
121
139
case entry.Type().IsRegular():
122
-
if file, err = root.Open(name); err != nil {
140
+
header.Typeflag = tar.TypeReg
141
+
if data, err = fs.ReadFile(root, name); err != nil {
123
142
return err
124
143
}
125
-
defer file.Close()
144
+
if *incrementalFlag && len(data) > incrementalSizeThreshold {
145
+
hash := gitBlobSHA256(data)
146
+
if _, requested := requestedSet[hash]; !requested {
147
+
header.Typeflag = tar.TypeSymlink
148
+
header.Linkname = "/git/blobs/" + hash
149
+
data = nil
150
+
}
151
+
}
126
152
case entry.Type() == fs.ModeSymlink:
127
-
if linkTarget, err = fs.ReadLink(root, name); err != nil {
153
+
header.Typeflag = tar.TypeSymlink
154
+
if header.Linkname, err = fs.ReadLink(root, name); err != nil {
128
155
return err
129
156
}
130
157
default:
131
158
return errors.New("tar: cannot add non-regular file")
132
159
}
133
-
header, err := tar.FileInfoHeader(fileInfo, linkTarget)
134
-
if err != nil {
160
+
header.Size = int64(len(data))
161
+
if err = tarWriter.WriteHeader(header); err != nil {
135
162
return err
136
163
}
137
-
header.Name = tarName
138
-
if err = tarWriter.WriteHeader(header); err != nil {
164
+
if _, err = tarWriter.Write(data); err != nil {
139
165
return err
140
-
}
141
-
if file != nil {
142
-
_, err = io.Copy(tarWriter, file)
143
166
}
144
167
return err
145
168
}); err != nil {
···
154
177
return
155
178
}
156
179
180
+
// Stream archive data without ever loading the entire working set into RAM.
181
+
func streamArchiveFS(root fs.FS, prefix string, needBlobs []string) io.ReadCloser {
182
+
reader, writer := io.Pipe()
183
+
go func() {
184
+
err := archiveFS(writer, root, prefix, needBlobs)
185
+
if err != nil {
186
+
writer.CloseWithError(err)
187
+
} else {
188
+
writer.Close()
189
+
}
190
+
}()
191
+
return reader
192
+
}
193
+
157
194
func makeWhiteout(path string) (reader io.Reader) {
158
195
buffer := &bytes.Buffer{}
159
196
tarWriter := tar.NewWriter(buffer)
···
211
248
}
212
249
213
250
var request *http.Request
251
+
var uploadDir *os.Root
214
252
switch {
215
253
case *challengeFlag || *challengeBareFlag:
216
254
if *passwordFlag == "" {
···
242
280
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
243
281
244
282
case *uploadDirFlag != "":
245
-
uploadDirFS, err := os.OpenRoot(*uploadDirFlag)
283
+
uploadDir, err = os.OpenRoot(*uploadDirFlag)
246
284
if err != nil {
247
285
fmt.Fprintf(os.Stderr, "error: invalid directory: %s\n", err)
248
286
os.Exit(1)
249
287
}
250
288
251
289
if *verboseFlag {
252
-
err := displayFS(uploadDirFS.FS(), pathPrefix)
290
+
err := displayFS(uploadDir.FS(), pathPrefix)
253
291
if err != nil {
254
292
fmt.Fprintf(os.Stderr, "error: %s\n", err)
255
293
os.Exit(1)
256
294
}
257
295
}
258
296
259
-
// Stream archive data without ever loading the entire working set into RAM.
260
-
reader, writer := io.Pipe()
261
-
go func() {
262
-
err = archiveFS(writer, uploadDirFS.FS(), pathPrefix)
263
-
if err != nil {
264
-
fmt.Fprintf(os.Stderr, "error: %s\n", err)
265
-
os.Exit(1)
266
-
}
267
-
writer.Close()
268
-
}()
269
-
270
297
if *pathFlag == "" {
271
-
request, err = http.NewRequest("PUT", siteURL.String(), reader)
298
+
request, err = http.NewRequest("PUT", siteURL.String(), nil)
272
299
} else {
273
-
request, err = http.NewRequest("PATCH", siteURL.String(), reader)
300
+
request, err = http.NewRequest("PATCH", siteURL.String(), nil)
274
301
}
275
302
if err != nil {
276
303
fmt.Fprintf(os.Stderr, "error: %s\n", err)
277
304
os.Exit(1)
278
305
}
306
+
request.Body = streamArchiveFS(uploadDir.FS(), pathPrefix, []string{})
279
307
request.ContentLength = -1
280
308
request.Header.Add("Content-Type", "application/x-tar+zstd")
309
+
request.Header.Add("Accept", "application/vnd.git-pages.unresolved;q=1.0, text/plain;q=0.9")
281
310
if *parentsFlag {
282
311
request.Header.Add("Create-Parents", "yes")
283
312
} else {
···
338
367
request.Header.Set("Host", siteURL.Host)
339
368
}
340
369
341
-
response, err := http.DefaultClient.Do(request)
342
-
if err != nil {
343
-
fmt.Fprintf(os.Stderr, "error: %s\n", err)
344
-
os.Exit(1)
345
-
}
346
-
if *verboseFlag {
347
-
fmt.Fprintf(os.Stderr, "server: %s\n", response.Header.Get("Server"))
348
-
}
349
-
if *debugManifestFlag {
350
-
if response.StatusCode == 200 {
351
-
io.Copy(os.Stdout, response.Body)
352
-
fmt.Fprintf(os.Stdout, "\n")
353
-
} else {
354
-
io.Copy(os.Stderr, response.Body)
370
+
displayServer := *verboseFlag
371
+
for {
372
+
response, err := http.DefaultClient.Do(request)
373
+
if err != nil {
374
+
fmt.Fprintf(os.Stderr, "error: %s\n", err)
355
375
os.Exit(1)
356
376
}
357
-
} else { // an update operation
358
-
if response.StatusCode == 200 {
359
-
fmt.Fprintf(os.Stdout, "result: %s\n", response.Header.Get("Update-Result"))
360
-
io.Copy(os.Stdout, response.Body)
361
-
} else {
362
-
fmt.Fprintf(os.Stderr, "result: error\n")
363
-
io.Copy(os.Stderr, response.Body)
364
-
os.Exit(1)
377
+
if displayServer {
378
+
fmt.Fprintf(os.Stderr, "server: %s\n", response.Header.Get("Server"))
379
+
displayServer = false
380
+
}
381
+
if *debugManifestFlag {
382
+
if response.StatusCode == http.StatusOK {
383
+
io.Copy(os.Stdout, response.Body)
384
+
fmt.Fprintf(os.Stdout, "\n")
385
+
} else {
386
+
io.Copy(os.Stderr, response.Body)
387
+
os.Exit(1)
388
+
}
389
+
} else { // an update operation
390
+
if *verboseFlag {
391
+
fmt.Fprintf(os.Stderr, "response: %d %s\n",
392
+
response.StatusCode, response.Header.Get("Content-Type"))
393
+
}
394
+
if response.StatusCode == http.StatusUnprocessableEntity &&
395
+
response.Header.Get("Content-Type") == "application/vnd.git-pages.unresolved" {
396
+
needBlobs := []string{}
397
+
scanner := bufio.NewScanner(response.Body)
398
+
for scanner.Scan() {
399
+
needBlobs = append(needBlobs, scanner.Text())
400
+
}
401
+
response.Body.Close()
402
+
if *verboseFlag {
403
+
fmt.Fprintf(os.Stderr, "incremental: need %d blobs\n", len(needBlobs))
404
+
}
405
+
request.Body = streamArchiveFS(uploadDir.FS(), pathPrefix, needBlobs)
406
+
continue // resubmit
407
+
} else if response.StatusCode == http.StatusOK {
408
+
fmt.Fprintf(os.Stdout, "result: %s\n", response.Header.Get("Update-Result"))
409
+
io.Copy(os.Stdout, response.Body)
410
+
} else {
411
+
fmt.Fprintf(os.Stderr, "result: error\n")
412
+
io.Copy(os.Stderr, response.Body)
413
+
os.Exit(1)
414
+
}
365
415
}
416
+
break
366
417
}
367
418
}