+76
-21
.forgejo/workflows/ci.yaml
+76
-21
.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:45babd1b4ce0349fb12c4e24bf017b90b96d52806db32e001e3013f341bef0fe
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
15
+
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
16
16
- name: Set up toolchain
17
-
uses: https://code.forgejo.org/actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
17
+
uses: https://code.forgejo.org/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
18
18
with:
19
19
go-version: '>=1.25.0'
20
20
- name: Install dependencies
21
21
run: |
22
22
apt-get -y update
23
23
apt-get -y install ca-certificates
24
+
go install honnef.co/go/tools/cmd/staticcheck@latest
24
25
- name: Build application
25
26
run: |
26
-
go build .
27
+
go build
27
28
- name: Run static analysis
28
29
run: |
29
-
go vet .
30
+
go vet
31
+
staticcheck
32
+
33
+
release:
34
+
# IMPORTANT: This workflow step will not work without the Releases unit enabled!
35
+
if: ${{ forge.ref == 'refs/heads/main' || startsWith(forge.event.ref, 'refs/tags/v') }}
36
+
needs: [check]
37
+
runs-on: debian-trixie
38
+
container:
39
+
image: docker.io/library/node:24-trixie-slim@sha256:b05474903f463ce4064c09986525e6588c3e66c51b69be9c93a39fb359f883ce
40
+
steps:
41
+
- name: Check out source code
42
+
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
43
+
- name: Set up toolchain
44
+
uses: https://code.forgejo.org/actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
45
+
with:
46
+
go-version: '>=1.25.0'
47
+
- name: Install dependencies
48
+
run: |
49
+
apt-get -y update
50
+
apt-get -y install ca-certificates
51
+
- name: Build release assets
52
+
# If you want more platforms to be represented, send a pull request.
53
+
run: |
54
+
set -x
55
+
build() { GOOS=$1 GOARCH=$2 go build -o assets/git-pages-cli.$1-$2$3; }
56
+
build windows amd64 .exe
57
+
build windows arm64 .exe
58
+
build linux amd64
59
+
build linux arm64
60
+
build darwin arm64
61
+
- name: Create release
62
+
uses: https://code.forgejo.org/actions/forgejo-release@fc0488c944626f9265d87fbc4dd6c08f78014c63 # v2.7.3
63
+
with:
64
+
tag: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }}
65
+
release-dir: assets
66
+
direction: upload
67
+
override: true
68
+
prerelease: ${{ !startsWith(forge.event.ref, 'refs/tags/v') }}
30
69
31
70
package:
32
-
if: ${{ forge.ref == 'refs/heads/main' }}
71
+
if: ${{ forge.ref == 'refs/heads/main' || startsWith(forge.event.ref, 'refs/tags/v') }}
33
72
needs: [check]
34
-
runs-on: codeberg-small-lazy
73
+
runs-on: debian-trixie
35
74
container:
36
-
image: docker.io/library/node:24-trixie-slim@sha256:45babd1b4ce0349fb12c4e24bf017b90b96d52806db32e001e3013f341bef0fe
75
+
image: docker.io/library/node:24-trixie-slim@sha256:b05474903f463ce4064c09986525e6588c3e66c51b69be9c93a39fb359f883ce
37
76
steps:
38
77
- name: Install dependencies
39
78
run: |
40
79
apt-get -y update
41
-
apt-get -y install buildah ca-certificates
80
+
apt-get -y install ca-certificates buildah qemu-user-binfmt
42
81
- name: Check out source code
43
-
uses: https://code.forgejo.org/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
44
-
- if: ${{ forge.repository == 'git-pages/git-pages-cli' && 'true' || 'false' }}
45
-
name: Log into container registry
82
+
uses: https://code.forgejo.org/actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
83
+
- name: Build container
84
+
run: |
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:amd64
87
+
buildah build --arch=arm64 --tag=container:arm64
88
+
buildah manifest create container container:amd64 container:arm64
89
+
env:
90
+
BUILDAH_ISOLATION: chroot
91
+
- if: ${{ forge.repository == 'git-pages/git-pages-cli' }}
92
+
name: Push container to Codeberg
46
93
run: |
47
94
buildah login --authfile=/tmp/authfile.json \
48
-
-u git-pages-bot -p ${{ secrets.PACKAGES_TOKEN }} codeberg.org
49
-
- name: Build container
50
-
uses: https://codeberg.org/actions/buildah-simple@main
51
-
with:
52
-
context: .
53
-
tag: "codeberg.org/git-pages/git-pages-cli:latest"
54
-
push: ${{ forge.repository == 'git-pages/git-pages-cli' && 'true' || 'false' }}
55
-
authfile: /tmp/authfile.json
95
+
-u ${{ vars.PACKAGES_USER }} -p ${{ secrets.PACKAGES_TOKEN }} ${FORGE}
96
+
buildah manifest push --authfile=/tmp/authfile.json \
97
+
--all container "docker://${FORGE}/${{ forge.repository }}:${VER/v/}"
98
+
env:
99
+
FORGE: codeberg.org
100
+
VER: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }}
101
+
- if: ${{ forge.repository == 'git-pages/git-pages-cli' }}
102
+
name: Push container to code.forgejo.org
103
+
run: |
104
+
buildah login --authfile=/tmp/authfile.json \
105
+
-u ${{ vars.PACKAGES_USER }} -p ${{ secrets.CFO_PACKAGES_TOKEN }} ${FORGE}
106
+
buildah manifest push --authfile=/tmp/authfile.json \
107
+
--all container "docker://${FORGE}/${{ forge.repository }}:${VER/v/}"
108
+
env:
109
+
FORGE: code.forgejo.org
110
+
VER: ${{ startsWith(forge.event.ref, 'refs/tags/v') && forge.ref_name || 'latest' }}
+3
-3
Dockerfile
+3
-3
Dockerfile
···
1
-
FROM docker.io/library/golang:1.25-alpine@sha256:aee43c3ccbf24fdffb7295693b6e33b21e01baec1b2a55acc351fde345e9ec34 AS builder
1
+
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.25-alpine@sha256:ac09a5f469f307e5da71e766b0bd59c9c49ea460a528cc3e6686513d64a6f1fb AS builder
2
+
ARG TARGETOS TARGETARCH
2
3
RUN apk --no-cache add ca-certificates git
3
4
WORKDIR /build
4
5
COPY go.mod go.sum ./
5
6
RUN go mod download
6
7
COPY *.go ./
7
-
RUN go build -ldflags "-s -w" .
8
+
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-s -w" .
8
9
9
10
FROM scratch
10
11
COPY --from=builder /etc/ssl/cert.pem /etc/ssl/cert.pem
11
12
COPY --from=builder /build/git-pages-cli /bin/git-pages-cli
12
-
13
13
ENTRYPOINT ["git-pages-cli"]
-14
LICENSE-0BSD.txt
-14
LICENSE-0BSD.txt
···
1
-
Copyright (C) git-pages contributors
2
-
Copyright (C) Catherine 'whitequark'
3
-
4
-
Permission to use, copy, modify, and/or distribute this software for
5
-
any purpose with or without fee is hereby granted.
6
-
7
-
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
-
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
-
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
-
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
-
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
12
-
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
13
-
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14
-
+14
LICENSE.txt
+14
LICENSE.txt
···
1
+
Copyright (C) git-pages contributors
2
+
Copyright (C) Catherine 'whitequark'
3
+
4
+
Permission to use, copy, modify, and/or distribute this software for
5
+
any purpose with or without fee is hereby granted.
6
+
7
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
12
+
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
13
+
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14
+
+87
-21
README.md
+87
-21
README.md
···
1
1
git-pages-cli
2
2
=============
3
3
4
-
_git-pages-cli_ is a command-line application for uploading sites to [git-pages].
4
+
_git-pages-cli_ is a command-line application for publishing sites to [git-pages].
5
5
6
-
If you want to publish a site from a Forgejo Actions workflow, use [git-pages/action] instead.
6
+
> [!TIP]
7
+
> If you want to publish a site from a CI workflow, use the [Forgejo Action][git-pages-action] instead.
7
8
8
9
[git-pages]: https://codeberg.org/git-pages/git-pages
9
-
[git-pages/action]: https://codeberg.org/git-pages/action
10
+
[git-pages-action]: https://codeberg.org/git-pages/action
10
11
11
12
12
13
Installation
13
14
------------
14
15
15
-
You will need [Go](https://go.dev/) 1.25 or newer. Run:
16
+
You can install _git-pages-cli_ using one of the following methods:
16
17
17
-
```console
18
-
$ go install codeberg.org/git-pages/git-pages-cli
19
-
```
18
+
1. **Downloading a binary**. You can download the [latest build][latest] or pick a [release][releases].
20
19
21
-
If you prefer, you may also use a [Docker container][docker]:
20
+
1. **Using a Docker container**. Choose between the latest build or a [release tag][containers]. Then run:
22
21
23
-
```console
24
-
docker run --rm codeberg.org/git-pages/git-pages-cli:latest ...
25
-
```
22
+
```console
23
+
$ docker run --rm codeberg.org/git-pages/git-pages-cli:latest ...
24
+
```
26
25
27
-
[docker]: https://codeberg.org/git-pages/-/packages/container/git-pages-cli/latest
26
+
1. **Installing from source**. First, install [Go](https://go.dev/) 1.25 or newer. Then run:
27
+
28
+
```console
29
+
$ go install codeberg.org/git-pages/git-pages-cli@latest
30
+
```
31
+
32
+
[latest]: https://codeberg.org/git-pages/git-pages-cli/releases/tag/latest
33
+
[releases]: https://codeberg.org/git-pages/git-pages-cli/releases
34
+
[containers]: https://codeberg.org/git-pages/-/packages/container/git-pages-cli/versions
28
35
29
36
30
37
Usage
···
33
40
To prepare a DNS challenge for a given site and password:
34
41
35
42
```console
36
-
$ git-pages-cli https://mycoolweb.site --password xyz --challenge
37
-
mycoolweb.site. 3600 IN TXT "317716dee4379c167e8b5ce9df38eb880e043e5a842d160fe8d5bb408ee0c191"
43
+
$ git-pages-cli https://example.org --challenge # generate a random password
44
+
password: 28a616f4-2fbe-456b-8456-056d1f38e815
45
+
_git-pages-challenge.example.org. 3600 IN TXT "a59ecb58f7256fc5afb6b96892501007b0b65d64f251b1aca749b0fca61d582c"
46
+
$ git-pages-cli https://example.org --password xyz --challenge
47
+
_git-pages-challenge.example.org. 3600 IN TXT "6c47172c027b3c79358f9f8c110886baf4826d9bc2a1c7d0f439cc770ed42dc8"
48
+
$ git-pages-cli https://example.org --password xyz --challenge-bare
49
+
6c47172c027b3c79358f9f8c110886baf4826d9bc2a1c7d0f439cc770ed42dc8
38
50
```
39
51
40
-
To deploy a site from a git repository available on the internet (`--password` may be omitted if the repository is allowlisted via DNS):
52
+
To publish a site from a git repository available on the internet (`--password` may be omitted if the repository is allowlisted via DNS):
41
53
42
54
```console
43
-
$ git-pages-cli https://mycoolweb.site --upload-git https://codeberg.org/username/mycoolweb.site.git
44
-
$ git-pages-cli https://mycoolweb.site --password xyz --upload-git https://codeberg.org/username/mycoolweb.site.git
55
+
$ git-pages-cli https://example.org --upload-git https://codeberg.org/username/example.org.git
56
+
$ git-pages-cli https://example.org --password xyz --upload-git https://codeberg.org/username/example.org.git
45
57
```
46
58
47
-
To deploy a site from a directory on your machine:
59
+
To publish a site from a directory on your machine:
48
60
49
61
```console
50
-
$ git-pages-cli https://mycoolweb.site --password xyz --upload-dir site-contents
62
+
$ git-pages-cli https://example.org --password xyz --upload-dir site-contents
51
63
```
52
64
53
65
To delete a site:
54
66
55
67
```console
56
-
$ git-pages-cli https://mycoolweb.site --password xyz --delete
68
+
$ git-pages-cli https://example.org --password xyz --delete
69
+
```
70
+
71
+
It is not possible to publish a site to a domain for the first time using HTTPS, since the git-pages server is not allowed to acquire a TLS certificate for a domain before a site is published on that domain. Either use plain HTTP instead, or provide a hostname for which the server *does* have a TLS certificate using the `--server` option:
72
+
73
+
```console
74
+
$ git-pages-cli https://example.org --server grebedoc.dev --password xyz --upload-dir ...
75
+
```
76
+
77
+
### Forge authorization
78
+
79
+
Uploading a directory to a site on a wildcard domain (e.g. `https://<owner>.grebedoc.dev/<repo>`) requires the use of an access token with push permissions for the corresponding repository (`https://codeberg.org/<owner>/<repo>.git` in this case).
80
+
81
+
To create such an access token on Forgejo:
82
+
1. Open _Settings_ > _Applications_ > _Access tokens_.
83
+
1. Expand _Select permissions_, pick _Read and write_ under _repository_.
84
+
1. Set _Token name_ to something informative (e.g. "git-pages publishing").
85
+
1. Click _Generate token_.
86
+
1. The token will appear in a notification (a long string of hexadecimal numbers all on its own).
87
+
88
+
To deploy using an access token:
89
+
90
+
```console
91
+
$ git-pages-cli https://username.grebedoc.dev --token <token> --upload-dir ...
92
+
```
93
+
94
+
**Keep the access token safe and secure!** Anyone who has it will be able to change the data in any of your repositories.
95
+
96
+
97
+
Advanced usage
98
+
--------------
99
+
100
+
To retrieve the site manifest (for debugging only: manifest schema is not versioned and **subject to change without notice**, including renaming of existing fields):
101
+
102
+
```console
103
+
$ git-pages-cli https://example.org --password xyz --debug-manifest
104
+
{
105
+
"contents": {
106
+
"": {
107
+
"type": "Directory"
108
+
},
109
+
"index.html": {
110
+
"type": "InlineFile",
111
+
"size": "5",
112
+
"data": "bWVvdwo=",
113
+
"contentType": "text/html; charset=utf-8"
114
+
}
115
+
},
116
+
"originalSize": "5",
117
+
"compressedSize": "5",
118
+
"storedSize": "0",
119
+
"redirects": [],
120
+
"headers": [],
121
+
"problems": []
122
+
}
57
123
```
58
124
59
125
60
126
License
61
127
-------
62
128
63
-
[0-clause BSD](LICENSE-0BSD.txt)
129
+
[0-clause BSD](LICENSE.txt)
+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-4Xo48Dpqzq61molFjhgu7df45544tRfjr0iM5k4dBVo=";
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
+3
-2
go.mod
+3
-2
go.mod
+4
-2
go.sum
+4
-2
go.sum
···
1
-
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
2
-
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
1
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
2
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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=
3
5
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
4
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="
+294
-59
main.go
+294
-59
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"
10
+
"errors"
7
11
"fmt"
8
12
"io"
9
13
"io/fs"
10
14
"net/http"
11
15
"net/url"
12
16
"os"
17
+
"runtime/debug"
18
+
"strconv"
19
+
"strings"
13
20
21
+
"github.com/google/uuid"
14
22
"github.com/klauspost/compress/zstd"
15
23
"github.com/spf13/pflag"
16
24
)
25
+
26
+
// By default the version information is retrieved from VCS. If not available during build,
27
+
// override this variable using linker flags to change the displayed version.
28
+
// Example: `-ldflags "-X main.versionOverride=v1.2.3"`
29
+
var versionOverride = ""
30
+
31
+
func versionInfo() string {
32
+
version := "(unknown)"
33
+
if versionOverride != "" {
34
+
version = versionOverride
35
+
} else if buildInfo, ok := debug.ReadBuildInfo(); ok {
36
+
version = buildInfo.Main.Version
37
+
}
38
+
return fmt.Sprintf("git-pages-cli %s", version)
39
+
}
17
40
18
41
var passwordFlag = pflag.String("password", "", "password for DNS challenge authorization")
19
-
var challengeFlag = pflag.Bool("challenge", false, "compute DNS challenge entry from password")
42
+
var tokenFlag = pflag.String("token", "", "token for forge authorization")
43
+
var challengeFlag = pflag.Bool("challenge", false, "compute DNS challenge entry from password (output zone file record)")
44
+
var challengeBareFlag = pflag.Bool("challenge-bare", false, "compute DNS challenge entry from password (output bare TXT value)")
20
45
var uploadGitFlag = pflag.String("upload-git", "", "replace site with contents of specified git repository")
21
-
var uploadDirFlag = pflag.String("upload-dir", "", "replace site with contents of specified directory")
22
-
var deleteFlag = pflag.Bool("delete", false, "delete site")
23
-
var verboseFlag = pflag.Bool("verbose", false, "display more information for debugging")
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")
48
+
var debugManifestFlag = pflag.Bool("debug-manifest", false, "retrieve site manifest as ProtoJSON, for debugging")
49
+
var serverFlag = pflag.String("server", "", "hostname of server to connect to")
50
+
var pathFlag = pflag.String("path", "", "partially update site at specified path")
51
+
var parentsFlag = pflag.Bool("parents", false, "create parent directories of --path")
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")
54
+
var verboseFlag = pflag.BoolP("verbose", "v", false, "display more information for debugging")
55
+
var versionFlag = pflag.BoolP("version", "V", false, "display version information")
24
56
25
57
func singleOperation() bool {
26
58
operations := 0
27
59
if *challengeFlag {
28
60
operations++
29
61
}
62
+
if *challengeBareFlag {
63
+
operations++
64
+
}
30
65
if *uploadDirFlag != "" {
31
66
operations++
32
67
}
···
36
71
if *deleteFlag {
37
72
operations++
38
73
}
74
+
if *debugManifestFlag {
75
+
operations++
76
+
}
77
+
if *versionFlag {
78
+
operations++
79
+
}
39
80
return operations == 1
40
81
}
41
82
42
-
func displayFS(root fs.FS) error {
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
+
92
+
func displayFS(root fs.FS, prefix string) error {
43
93
return fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
44
94
if err != nil {
45
95
return err
46
96
}
47
97
switch {
48
-
case entry.Type() == 0:
49
-
fmt.Fprintln(os.Stderr, "file", name)
50
-
case entry.Type() == fs.ModeDir:
51
-
fmt.Fprintln(os.Stderr, "dir", name)
98
+
case entry.Type().IsDir():
99
+
fmt.Fprintf(os.Stderr, "dir %s%s\n", prefix, name)
100
+
case entry.Type().IsRegular():
101
+
fmt.Fprintf(os.Stderr, "file %s%s\n", prefix, name)
52
102
case entry.Type() == fs.ModeSymlink:
53
-
fmt.Fprintln(os.Stderr, "symlink", name)
103
+
fmt.Fprintf(os.Stderr, "symlink %s%s\n", prefix, name)
54
104
default:
55
-
fmt.Fprintln(os.Stderr, "other", name)
105
+
fmt.Fprintf(os.Stderr, "other %s%s\n", prefix, name)
56
106
}
57
107
return nil
58
108
})
59
109
}
60
110
61
-
func archiveFS(root fs.FS) (result []byte, err error) {
62
-
buffer := bytes.Buffer{}
63
-
zstdWriter, _ := zstd.NewWriter(&buffer)
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
+
}
120
+
zstdWriter, _ := zstd.NewWriter(writer)
64
121
tarWriter := tar.NewWriter(zstdWriter)
65
-
err = tarWriter.AddFS(root)
66
-
if err != nil {
122
+
if err = fs.WalkDir(root, ".", func(name string, entry fs.DirEntry, err error) error {
123
+
if err != nil {
124
+
return err
125
+
}
126
+
header := &tar.Header{}
127
+
data := []byte{}
128
+
if prefix == "" && name == "." {
129
+
return nil
130
+
} else if name == "." {
131
+
header.Name = prefix
132
+
} else {
133
+
header.Name = prefix + name
134
+
}
135
+
switch {
136
+
case entry.Type().IsDir():
137
+
header.Typeflag = tar.TypeDir
138
+
header.Name += "/"
139
+
case entry.Type().IsRegular():
140
+
header.Typeflag = tar.TypeReg
141
+
if data, err = fs.ReadFile(root, name); err != nil {
142
+
return err
143
+
}
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
+
}
152
+
case entry.Type() == fs.ModeSymlink:
153
+
header.Typeflag = tar.TypeSymlink
154
+
if header.Linkname, err = fs.ReadLink(root, name); err != nil {
155
+
return err
156
+
}
157
+
default:
158
+
return errors.New("tar: cannot add non-regular file")
159
+
}
160
+
header.Size = int64(len(data))
161
+
if err = tarWriter.WriteHeader(header); err != nil {
162
+
return err
163
+
}
164
+
if _, err = tarWriter.Write(data); err != nil {
165
+
return err
166
+
}
167
+
return err
168
+
}); err != nil {
67
169
return
68
170
}
69
-
err = tarWriter.Close()
70
-
if err != nil {
171
+
if err = tarWriter.Close(); err != nil {
71
172
return
72
173
}
73
-
err = zstdWriter.Close()
74
-
if err != nil {
174
+
if err = zstdWriter.Close(); err != nil {
75
175
return
76
176
}
77
-
result = buffer.Bytes()
78
177
return
79
178
}
80
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
+
194
+
func makeWhiteout(path string) (reader io.Reader) {
195
+
buffer := &bytes.Buffer{}
196
+
tarWriter := tar.NewWriter(buffer)
197
+
tarWriter.WriteHeader(&tar.Header{
198
+
Typeflag: tar.TypeChar,
199
+
Name: path,
200
+
})
201
+
tarWriter.Flush()
202
+
return buffer
203
+
}
204
+
205
+
const usageExitCode = 125
206
+
207
+
func usage() {
208
+
fmt.Fprintf(os.Stderr,
209
+
"Usage: %s <site-url> {--challenge|--upload-git url|--upload-dir path|--delete} [options...]\n",
210
+
os.Args[0],
211
+
)
212
+
pflag.PrintDefaults()
213
+
}
214
+
81
215
func main() {
216
+
pflag.Usage = usage
82
217
pflag.Parse()
83
-
if !singleOperation() || len(pflag.Args()) != 1 {
84
-
fmt.Fprintf(os.Stderr,
85
-
"Usage: %s <site-url> [--challenge|--upload-git url|--upload-dir path|--delete]\n",
86
-
os.Args[0],
87
-
)
88
-
os.Exit(125)
218
+
if !singleOperation() || (!*versionFlag && len(pflag.Args()) != 1) {
219
+
pflag.Usage()
220
+
os.Exit(usageExitCode)
221
+
}
222
+
223
+
if *versionFlag {
224
+
fmt.Fprintln(os.Stdout, versionInfo())
225
+
os.Exit(0)
226
+
}
227
+
228
+
if *passwordFlag != "" && *tokenFlag != "" {
229
+
fmt.Fprintf(os.Stderr, "--password and --token are mutually exclusive")
230
+
os.Exit(usageExitCode)
231
+
}
232
+
233
+
var pathPrefix string
234
+
if *pathFlag != "" {
235
+
if *uploadDirFlag == "" && !*deleteFlag {
236
+
fmt.Fprintf(os.Stderr, "--path requires --upload-dir or --delete")
237
+
os.Exit(usageExitCode)
238
+
} else {
239
+
pathPrefix = strings.Trim(*pathFlag, "/") + "/"
240
+
}
89
241
}
90
242
91
243
var err error
92
-
siteUrl, err := url.Parse(pflag.Args()[0])
244
+
siteURL, err := url.Parse(pflag.Args()[0])
93
245
if err != nil {
94
246
fmt.Fprintf(os.Stderr, "error: invalid site URL: %s\n", err)
95
247
os.Exit(1)
96
248
}
97
249
98
250
var request *http.Request
251
+
var uploadDir *os.Root
99
252
switch {
100
-
case *challengeFlag:
253
+
case *challengeFlag || *challengeBareFlag:
101
254
if *passwordFlag == "" {
102
-
fmt.Fprintf(os.Stderr, "error: no --password option specified\n")
103
-
os.Exit(1)
255
+
*passwordFlag = uuid.NewString()
256
+
fmt.Fprintf(os.Stderr, "password: %s\n", *passwordFlag)
104
257
}
105
258
106
-
challenge := sha256.Sum256(fmt.Appendf(nil, "%s %s", siteUrl.Hostname(), *passwordFlag))
107
-
fmt.Fprintf(os.Stdout, "%s. 3600 IN TXT \"%x\"\n", siteUrl.Hostname(), challenge)
259
+
challenge := sha256.Sum256(fmt.Appendf(nil, "%s %s", siteURL.Hostname(), *passwordFlag))
260
+
if *challengeBareFlag {
261
+
fmt.Fprintf(os.Stdout, "%x\n", challenge)
262
+
} else {
263
+
fmt.Fprintf(os.Stdout, "_git-pages-challenge.%s. 3600 IN TXT \"%x\"\n", siteURL.Hostname(), challenge)
264
+
}
108
265
os.Exit(0)
109
266
110
267
case *uploadGitFlag != "":
···
115
272
}
116
273
117
274
requestBody := []byte(uploadGitUrl.String())
118
-
request, err = http.NewRequest("PUT", siteUrl.String(), bytes.NewReader(requestBody))
275
+
request, err = http.NewRequest("PUT", siteURL.String(), bytes.NewReader(requestBody))
119
276
if err != nil {
120
277
fmt.Fprintf(os.Stderr, "error: %s\n", err)
121
278
os.Exit(1)
···
123
280
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
124
281
125
282
case *uploadDirFlag != "":
126
-
uploadDirFS, err := os.OpenRoot(*uploadDirFlag)
283
+
uploadDir, err = os.OpenRoot(*uploadDirFlag)
127
284
if err != nil {
128
285
fmt.Fprintf(os.Stderr, "error: invalid directory: %s\n", err)
129
286
os.Exit(1)
130
287
}
131
288
132
289
if *verboseFlag {
133
-
err := displayFS(uploadDirFS.FS())
290
+
err := displayFS(uploadDir.FS(), pathPrefix)
134
291
if err != nil {
135
292
fmt.Fprintf(os.Stderr, "error: %s\n", err)
136
293
os.Exit(1)
137
294
}
138
295
}
139
296
140
-
requestBody, err := archiveFS(uploadDirFS.FS())
141
-
if err != nil {
142
-
fmt.Fprintf(os.Stderr, "error: %s\n", err)
143
-
os.Exit(1)
297
+
if *pathFlag == "" {
298
+
request, err = http.NewRequest("PUT", siteURL.String(), nil)
299
+
} else {
300
+
request, err = http.NewRequest("PATCH", siteURL.String(), nil)
144
301
}
145
-
146
-
request, err = http.NewRequest("PUT", siteUrl.String(), bytes.NewReader(requestBody))
147
302
if err != nil {
148
303
fmt.Fprintf(os.Stderr, "error: %s\n", err)
149
304
os.Exit(1)
150
305
}
306
+
request.Body = streamArchiveFS(uploadDir.FS(), pathPrefix, []string{})
307
+
request.ContentLength = -1
151
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")
310
+
if *parentsFlag {
311
+
request.Header.Add("Create-Parents", "yes")
312
+
} else {
313
+
request.Header.Add("Create-Parents", "no")
314
+
}
152
315
153
316
case *deleteFlag:
154
-
request, err = http.NewRequest("DELETE", siteUrl.String(), bytes.NewReader([]byte{}))
317
+
if *pathFlag == "" {
318
+
request, err = http.NewRequest("DELETE", siteURL.String(), nil)
319
+
if err != nil {
320
+
fmt.Fprintf(os.Stderr, "error: %s\n", err)
321
+
os.Exit(1)
322
+
}
323
+
} else {
324
+
request, err = http.NewRequest("PATCH", siteURL.String(), makeWhiteout(pathPrefix))
325
+
if err != nil {
326
+
fmt.Fprintf(os.Stderr, "error: %s\n", err)
327
+
os.Exit(1)
328
+
}
329
+
request.Header.Add("Content-Type", "application/x-tar")
330
+
}
331
+
332
+
case *debugManifestFlag:
333
+
manifestURL := siteURL.ResolveReference(&url.URL{Path: ".git-pages/manifest.json"})
334
+
request, err = http.NewRequest("GET", manifestURL.String(), nil)
155
335
if err != nil {
156
336
fmt.Fprintf(os.Stderr, "error: %s\n", err)
157
337
os.Exit(1)
···
160
340
default:
161
341
panic("no operation chosen")
162
342
}
163
-
if *passwordFlag != "" {
164
-
request.Header.Add("Authorization", fmt.Sprintf("Pages %s", *passwordFlag))
343
+
request.Header.Add("User-Agent", versionInfo())
344
+
if request.Method == "PATCH" {
345
+
if *atomicFlag {
346
+
request.Header.Add("Atomic", "yes")
347
+
request.Header.Add("Race-Free", "yes") // deprecated name, to be removed soon
348
+
} else {
349
+
request.Header.Add("Atomic", "no")
350
+
request.Header.Add("Race-Free", "no") // deprecated name, to be removed soon
351
+
}
165
352
}
166
-
167
-
response, err := http.DefaultClient.Do(request)
168
-
if err != nil {
169
-
fmt.Fprintf(os.Stderr, "error: %s\n", err)
170
-
os.Exit(1)
353
+
switch {
354
+
case *passwordFlag != "":
355
+
request.Header.Add("Authorization", fmt.Sprintf("Pages %s", *passwordFlag))
356
+
case *tokenFlag != "":
357
+
request.Header.Add("Forge-Authorization", fmt.Sprintf("token %s", *tokenFlag))
171
358
}
172
-
if *verboseFlag {
173
-
fmt.Fprintf(os.Stderr, "server: %s\n", response.Header.Get("Server"))
359
+
if *serverFlag != "" {
360
+
// Send the request to `--server` host, but set the `Host:` header to the site host.
361
+
// This allows first-time publishing to proceed without the git-pages server yet having
362
+
// a TLS certificate for the site host (which has a circular dependency on completion of
363
+
// first-time publishing).
364
+
newURL := *request.URL
365
+
newURL.Host = *serverFlag
366
+
request.URL = &newURL
367
+
request.Header.Set("Host", siteURL.Host)
174
368
}
175
-
if response.StatusCode == 200 {
176
-
fmt.Fprintf(os.Stdout, "result: %s\n", response.Header.Get("Update-Result"))
177
-
os.Exit(0)
178
-
} else {
179
-
fmt.Fprintf(os.Stderr, "result: error\n")
180
-
io.Copy(os.Stderr, response.Body)
181
-
os.Exit(1)
369
+
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)
375
+
os.Exit(1)
376
+
}
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
+
}
415
+
}
416
+
break
182
417
}
183
418
}