commit 788cf0571f4b77b7e513350ce2a0c5578a50c40c Author: Petr Balvín Date: Thu Sep 10 04:00:00 2026 +0000 feat: initial release of the scripts collection Assisted-by: GLM 5.3 Flash diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..9dce71e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,104 @@ +# Deploy: the scripts over rsync. Runs on every push to main and on manual dispatch, +# because scripts are not versioned and go live immediately. +# +# Requires secrets: DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, DEPLOY_PASSWORD, +# DEPLOY_KNOWN_HOSTS (the host key, from ssh-keyscan -t ed25519 HOST). +# +# Every step is one command, and the scripted steps are Perl rather than shell, so +# nothing has to be trusted to a shell option and no shell ever parses an argument. +# The Perl uses builtins only. The host key is pinned from the secret before the +# first connection: pointing UserKnownHostsFile at /dev/null would disable +# verification and turn every deploy into a blind trust on first use. +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: alpine + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - name: Install Perl + # The alpine base image carries no Perl. + run: apk add --no-cache perl + + - name: Generate SHA256SUMS + run: | + perl -e ' + my @files = sort glob q{*.pl}; + @files or die qq{ERROR: no scripts found\n}; + open(my $out, q{>}, q{SHA256SUMS}) or die qq{SHA256SUMS: $!\n}; + for my $file (@files) { + open(my $sums, q{-|}, q{sha256sum}, $file) or die qq{sha256sum: $!\n}; + my $line = <$sums>; + # close waits for the child and answers false when it failed. + close($sums) or die qq{ERROR: sha256sum failed for $file\n}; + defined $line or die qq{ERROR: sha256sum produced nothing for $file\n}; + print $out $line; + print $line; + } + close($out) or die qq{SHA256SUMS: $!\n}; + ' + + - name: Pin the host key + env: + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + run: | + # The runner has no persistent known_hosts, so the key comes from a secret + # and is written before the first connection. + perl -e ' + my $key = $ENV{DEPLOY_KNOWN_HOSTS} // q{}; + $key =~ m{\S} or die qq{ERROR: DEPLOY_KNOWN_HOSTS is empty\n}; + my $dir = ($ENV{HOME} // q{.}) . q{/.ssh}; + mkdir($dir, 0700) unless -d $dir; + open(my $out, q{>}, qq{$dir/known_hosts}) or die qq{known_hosts: $!}; + print $out $key; + $key =~ m{\n\z} or print $out qq{\n}; + close($out); + chmod(0600, qq{$dir/known_hosts}) or die qq{chmod: $!}; + print qq{host key pinned in $dir/known_hosts\n}; + ' + + - name: Deploy via rsync + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + DEPLOY_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }} + run: | + # rsync is driven from Perl through system() with a list, so no shell ever + # parses the password, the remote path or the ssh options. The published + # set is staged into one directory and mirrored whole, because --delete + # only prunes when rsync walks a directory: a file list transfer would + # leave a script removed from the repository live on the host. + perl -e ' + my $host = $ENV{DEPLOY_HOST} // die qq{ERROR: DEPLOY_HOST is unset\n}; + my $user = $ENV{DEPLOY_USER} // die qq{ERROR: DEPLOY_USER is unset\n}; + my $path = $ENV{DEPLOY_PATH} // die qq{ERROR: DEPLOY_PATH is unset\n}; + my $pass = $ENV{DEPLOY_PASSWORD} // die qq{ERROR: DEPLOY_PASSWORD is unset\n}; + my @files = sort glob q{*.pl}; + @files or die qq{ERROR: no scripts found\n}; + my $stage = ($ENV{HOME} // q{.}) . q{/.deploy-stage.} . $$; + mkdir($stage, 0700) or die qq{ERROR: cannot create the staging directory: $!\n}; + for my $file (@files, q{SHA256SUMS}) { + system(q{cp}, q{-p}, $file, $stage) == 0 + or die qq{ERROR: cannot stage $file\n}; + } + # The staged files are public on the host, so the directory must stay + # traversable by nginx: rsync -a would carry 0700 over and every URL + # behind it would answer 403. + chmod(0755, $stage) or die qq{ERROR: cannot chmod the staging directory: $!\n}; + print qq{Deploying to $user\@$host:$path\n}; + my @cmd = (q{sshpass}, q{-p}, $pass, q{rsync}, q{-avz}, q{--delete}, + q{-e}, q{ssh -o StrictHostKeyChecking=yes}, qq{$stage/}, + qq{$user\@$host:$path}); + system(@cmd) == 0 or die qq{ERROR: rsync failed\n}; + system(q{rm}, q{-rf}, $stage) == 0 + or die qq{ERROR: cannot remove the staging directory\n}; + print qq{Deploy complete.\n}; + ' diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..b7b049f --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,107 @@ +# Test: the push-path gates for the scripts collection. Runs on every push and pull +# request against development, never on main, which is release-only. +# +# The gates are the same set the repository documents in docs/DEVELOPMENT.md: every +# script compiles, carries its licence header, uses no dash as punctuation, and passes +# its own checks under tests/. They are the affordable ones: the container rigs that +# verify a script against a real system need podman and a privileged container, which +# belong to the machine at the keyboard, not to a shared runner. +# +# Every step is one command, and the scripted steps are Perl rather than shell, so no +# shell option has to be trusted for the run to stop. The Perl uses builtins only: +# the runner packages the modules separately. +name: Test + +on: + push: + branches: [development] + pull_request: + branches: [development] + +jobs: + test: + runs-on: fedora + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - name: Install Perl + run: dnf install -y perl + + - name: Every script compiles + run: | + perl -e ' + my @files = sort glob q{*.pl}; + @files or die qq{ERROR: no scripts found\n}; + for my $file (@files) { + system(q{perl}, q{-c}, $file) == 0 or die qq{ERROR: $file does not compile\n}; + } + print qq{compiled @files[0..$#files]: }, scalar(@files), qq{ scripts\n}; + ' + + - name: Every script carries the licence header + run: | + perl -e ' + my @files = sort glob q{*.pl}; + @files or die qq{ERROR: no scripts found\n}; + for my $file (@files) { + open(my $fh, q{<}, $file) or die qq{ERROR: $file: $!\n}; + my @head = map { my $line = <$fh> // q{}; chomp $line; $line } 1 .. 3; + close($fh); + $head[0] =~ m{^#!/usr/bin/env perl\z} + or die qq{ERROR: $file has no Perl shebang in the form this collection uses\n}; + # The name carries a non-ASCII letter and the files are byte strings, + # so its UTF-8 bytes are matched rather than a wildcard width. + $head[1] =~ m{^# Copyright \(c\) \d{4} Petr Balv\xc3\xadn \(https://petrbalvin\.org\)\z} + or die qq{ERROR: $file has no copyright line\n}; + $head[2] =~ m{^# SPDX-License-Identifier: MIT\z} + or die qq{ERROR: $file has no SPDX line, or names another licence\n}; + } + print qq{licence header present in }, scalar(@files), qq{ scripts\n}; + ' + + - name: No dash as punctuation + run: | + perl -e ' + my @files = (sort(glob q{*.pl}), sort(glob q{tests/*.pl}), sort(glob q{*.md}), + sort(glob q{.gitea/workflows/*.yml})); + @files or die qq{ERROR: no files found\n}; + my $found = 0; + for my $file (@files) { + open(my $fh, q{<}, $file) or die qq{ERROR: $file: $!\n}; + my $number = 0; + while (my $line = <$fh>) { + $number++; + # The bytes matter: the files are byte strings, so the em dash and + # the en dash are matched as their UTF-8 sequences. + if ($line =~ /\xe2\x80\x94|\xe2\x80\x93/) { + print qq{ERROR: $file:$number uses a dash as punctuation\n}; + $found++; + } + } + close($fh); + } + die qq{ERROR: $found line(s) use a dash as punctuation\n} if $found; + print qq{no dash used as punctuation in }, scalar(@files), qq{ files\n}; + ' + + - name: network-diag.pl + run: perl tests/network-diag.pl + + - name: security-audit.pl + run: perl tests/security-audit.pl + + - name: server-setup.pl + run: perl tests/server-setup.pl + + - name: sglang-deploy.pl + run: perl tests/sglang-deploy.pl + + - name: system-diag.pl + run: perl tests/system-diag.pl + + - name: system-optimise.pl + run: perl tests/system-optimise.pl + + - name: workstation-setup.pl + run: perl tests/workstation-setup.pl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bd27cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +.zcode/ + +# Generated by CI, deployed alongside the scripts +SHA256SUMS diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..556dbae --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,114 @@ +# Contributing + +Thanks for contributing to **scripts**. + +## Development setup + +Requirements: Perl 5.38 or newer, which is what the oldest supported system ships +(CentOS Stream 10 carries 5.40, openEuler 24.03 LTS carries 5.38). No module beyond the +interpreter is needed, by design: the scripts use builtins only. Podman is needed only +for the container rigs described in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). + +```sh +git clone https://sourcedock.dev/petrbalvin/scripts.git +cd scripts +perl -c network-diag.pl +perl tests/network-diag.pl +``` + +## Workflow + +1. Branch from `development`. Never commit directly to `main`, which is release-only. +2. Commit in [Conventional Commits](https://www.conventionalcommits.org/) form: + `type(scope): description`, subject line only, imperative mood, lowercase after the + colon, no trailing full stop. Allowed types: `feat`, `fix`, `docs`, `style`, + `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`. +3. One logical change per commit. A refactor, a behaviour change and a formatting pass + are three commits, never one. +4. Add or extend the checks in `tests/` for the code you touched. A new script arrives + with its own `tests/