[mirror] Command-line application for uploading a site to a git-pages server

Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 3 go 1.25.0 4 4 5 5 require ( 6 - github.com/spf13/pflag v1.0.10 7 - github.com/klauspost/compress v1.18.1 6 + github.com/google/uuid v1.6.0 7 + github.com/klauspost/compress v1.18.2 8 + github.com/spf13/pflag v1.0.10 8 9 )
+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
··· 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
··· 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 }
+4 -1
renovate.json
··· 5 5 ], 6 6 "abandonmentThreshold": null, 7 7 "packageRules": [], 8 + "automerge": false, 8 9 "lockFileMaintenance": { 9 10 "enabled": true, 10 11 "automerge": false 11 - } 12 + }, 13 + "semanticCommits": "disabled", 14 + "commitMessagePrefix": "[Renovate]" 12 15 }