Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ trim_trailing_whitespace = true
[*.sh]
indent_style = tab

# The carried tree is byte-identical to the hub's copy, which indents with four spaces.
[lib/fleet/**.sh]
indent_style = space
indent_size = 4

# Markdown files
# Two trailing spaces are a hard line break in Markdown, so trimming them silently rewrites content.
[*.md]
Expand Down
2 changes: 1 addition & 1 deletion .secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ than the documented shape.
| `~/.secrets/blog_vps_staging.env` | The VPS staging environment. |

Every value each file holds, and what reads it, is described once in
[`ENVIRONMENT.md`](../ENVIRONMENT.md). Start a new one from [`example.env`](./example.env).
[`ENVIRONMENT.md`](../ENVIRONMENT.md). Start a new one from [`example.env`](./example.env), then `chmod 600` it, since `make-release.sh` refuses a file that group or other can read.
2 changes: 1 addition & 1 deletion .secrets/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
# blog_vps_production.env ENV_FILE=blog_vps_production.env deploy/make-release.sh
# blog_vps_staging.env ENV_FILE=blog_vps_staging.env deploy/make-release.sh
#
# Sourced with `set -a`, which overwrites a variable the caller exported first. A named
# make-release.sh reads it over any value the caller exported first, and refuses a file that group or other can read. A named
# file that does not exist is a hard failure rather than a fall-through. Real files live
# in ~/.secrets/, never in this checkout, so no value naming a machine reaches this history.

Expand Down
6 changes: 4 additions & 2 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"command": "shellcheck",
"args": [
"deploy/make-release.sh",
"checks/check-live-urls.sh"
"checks/check-live-urls.sh",
"lib/fleet/sh/secrets.sh"
],
"problemMatcher": []
},
Expand All @@ -38,7 +39,8 @@
"args": [
"-d",
"deploy/make-release.sh",
"checks/check-live-urls.sh"
"checks/check-live-urls.sh",
"lib/fleet/sh/secrets.sh"
],
"problemMatcher": []
},
Expand Down
6 changes: 3 additions & 3 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ Every configuration value this repository reads or writes, described once. [`OPE

## The mechanism

A local run reads one file. `~/.secrets/blog_<server>_<environment>.env` is sourced with `set -a`, selected by `ENV_FILE`, and defaults to `~/.secrets/blog_local_production.env`. The real files live on the host, never in this checkout, and [`.secrets/example.env`](./.secrets/example.env) is the tracked template that documents the shape.
A local run reads one file. `~/.secrets/blog_<server>_<environment>.env` is selected by `ENV_FILE`, and defaults to `~/.secrets/blog_local_production.env`. `make-release.sh` checks it with the carried `lib/fleet/sh/secrets.sh` readers. It reads only the values it needs, each in a child shell, rather than sourcing the whole file. A file must give group and other no access and use LF line endings, or the script refuses it and says why. The real files live on the host, never in this checkout, and [`.secrets/example.env`](./.secrets/example.env) is the tracked template that documents the shape.

Two consequences of `set -a` are worth stating because both have surprised someone. Sourcing overwrites a variable the caller exported first, so exporting `DEPLOY_ROOT` by hand does not switch environments and only `ENV_FILE` does. And a named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root.
Two consequences are worth stating because both have surprised someone. Once a file is read, its `DEPLOY_ROOT`, `DEPLOY_SSH_HOST`, and `SITE_BASE_URL` replace any value the caller exported, even where the file leaves one unset. So exporting `DEPLOY_ROOT` by hand does not switch environments, and only `ENV_FILE` does. And a named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root.

CI reads no file. The deploy workflow resolves the same values from the GitHub Environment, which is why the shapes have to match even though the sources do not.

Expand Down Expand Up @@ -74,7 +74,7 @@ Set on the command line for one run rather than stored anywhere.

| Value | Effect |
| --- | --- |
| `ENV_FILE` | which environment file to source. Defaults to `~/.secrets/blog_local_production.env` |
| `ENV_FILE` | which environment file to read. Defaults to `~/.secrets/blog_local_production.env` |
| `REQUIRE_BROTLI=1` | fail rather than shipping gzip-only. CI sets it |
| `NO_LINK_DEST=1` | full copy instead of hard-linking from the previous release |
| `KEEP_RELEASES` | how many releases `make-release.sh` leaves behind |
Expand Down
12 changes: 6 additions & 6 deletions OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ ENV_FILE=~/.secrets/blog_local_production.env deploy/make-release.sh "" "$RELEAS
EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL"
```

Name the file in both places, even when it is the default, since `make-release.sh` sources `ENV_FILE` independently of the shell above and a value already exported earlier in the same session would otherwise win silently over the sourced one. The empty first argument leaves the deploy root at the sourced `DEPLOY_ROOT`, and `RELEASE` is reused so `EXPECT_RELEASE` verifies the release the command just built rather than skipping the release-stamp guard. `set -e` matters here too: a failed `git rev-parse` would otherwise leave `RELEASE` empty, which silently skips the check's own release-stamp verification instead of failing loud:
Name the file in both places, even when it is the default, since `make-release.sh` reads `ENV_FILE` independently of the shell above and a value already exported earlier in the same session would otherwise win silently over the sourced one. The empty first argument leaves the deploy root at the sourced `DEPLOY_ROOT`, and `RELEASE` is reused so `EXPECT_RELEASE` verifies the release the command just built rather than skipping the release-stamp guard. `set -e` matters here too: a failed `git rev-parse` would otherwise leave `RELEASE` empty, which silently skips the check's own release-stamp verification instead of failing loud:

```sh
set -e
Expand Down Expand Up @@ -67,7 +67,7 @@ EXPECT_RELEASE=<version> checks/check-live-urls.sh "$SITE_BASE_URL"

Sourcing the environment file first puts the deploy root and the base URL in the environment, so no literal value is typed. `make-release.sh` then needs no arguments, because its deploy root falls back to `$DEPLOY_ROOT` and its version falls back to a timestamp. It still accepts both arguments, and the deploy workflow passes a bundle path and a version, as [Deploying](#deploying) below shows. Either form works locally, and the argument wins over the environment.

`ENV_FILE` is set as well as sourced, and the redundancy is deliberate. The script sources its own file regardless, so leaving `ENV_FILE` off would build and install against `~/.secrets/blog_local_production.env` while the shell's `$SITE_BASE_URL` still named staging, and the run would check the staging site after publishing to the production root. The script prints the file it read, on every build, for that reason.
`ENV_FILE` is set as well as sourced, and the redundancy is deliberate. The script reads its own file regardless, so leaving `ENV_FILE` off would build and install against `~/.secrets/blog_local_production.env` while the shell's `$SITE_BASE_URL` still named staging, and the run would check the staging site after publishing to the production root. The script prints the file it read, on every build, for that reason.

It refuses to install a release that fails the build gate. `check-live-urls.sh` does take a base URL, which is where the sourced `$SITE_BASE_URL` goes. It follows every URL in the contract against the running mirror, checking each redirect's destination rather than trusting its status code.

Expand Down Expand Up @@ -119,9 +119,9 @@ SITE_BASE_URL=<base-url> deploy/make-release.sh <bundle-path> <release-id>
EXPECT_RELEASE=<release-id> checks/check-live-urls.sh <base-url>
```

The deploy root and the base URL are the only host-specific values. A local run reads them from a file under `~/.secrets/`, one per environment, copied from [`.secrets/example.env`](./.secrets/example.env). CI passes the base URL and a scratch bundle path, then uploads the bundle to the host's deploy root. The real files live on the host, never in this checkout.
The deploy root and the base URL are the only host-specific values. A local run reads them from a file under `~/.secrets/`, one per environment, copied from [`.secrets/example.env`](./.secrets/example.env). Make each one mode 600, since `make-release.sh` refuses a file that group or other can read. CI passes the base URL and a scratch bundle path, then uploads the bundle to the host's deploy root. The real files live on the host, never in this checkout.

**The command-prefix form above is CI-only.** A local run whose default environment file exists sources it after the command-prefix assignment and overwrites it, since `set -a` overwrites a value the caller exported first. Locally, select the environment through `ENV_FILE` instead, as the two examples earlier in this section do.
**The command-prefix form above is CI-only.** A local run whose default environment file exists reads it after the command-prefix assignment and overwrites it. A value read from the file replaces one the caller exported. Locally, select the environment through `ENV_FILE` instead, as the two examples earlier in this section do.

**A VPS release is named by its deploy run, never by a commit.** The hub's deploy task names each release `<UTC yyyymmdd-HHMMSS>-<run id>-<run attempt>` and verifies the site against that id itself. To check a VPS site by hand afterwards, pass that id as `EXPECT_RELEASE`. Read it from the **Deploy site job**'s log, where `make-release.sh` prints `==> installing release <id>` with the id ending in the run's own id and attempt. The Validate sources job prints the same line earlier in the run, for a scratch bundle stamped with a bare timestamp, so the first match of a log search is the wrong id. Never copy it from the site's `X-Blog-Release` header, since a stale config still serves the previous id and the check would then pass against it. A short SHA never matches, so the check waits out its timeout and fails.

Expand All @@ -139,7 +139,7 @@ The deploy root and the base URL are the only host-specific values. A local run

| Variable | Effect |
| --- | --- |
| `ENV_FILE` | Which environment file to source. Defaults to `~/.secrets/blog_local_production.env`. |
| `ENV_FILE` | Which environment file to read. Defaults to `~/.secrets/blog_local_production.env`. |
| `DEPLOY_ROOT` | Fallback deploy root. The first argument wins. |
| `SITE_BASE_URL` | Overrides the site base URL. |
| `REQUIRE_BROTLI=1` | Fails rather than shipping gzip-only. CI sets this. |
Expand Down Expand Up @@ -497,7 +497,7 @@ The local mirrors are not staging. They run the same bundle against the same web

**The two words are `production` and `staging`, spelled out, in every position.** No `prod`, no `stage`. The same two name the container, the deploy root, the environment file, the `X-Blog-Env` value, and the GitHub Environment. This is not tidiness: the environment name is a value that gets **compared**, by `EXPECT_SITE_ENV` and by the deploy, so a spelling that differs in one position fails a deploy for a reason that reads like an outage. The local mirrors prefix the same words, `mirror-production` and `mirror-staging`, so a header names exactly one of the four environments in the fleet.

Each environment is one file under `~/.secrets/`, named `blog_<server>_<environment>.env`, selected with `ENV_FILE`, and holding the deploy root, the base URL, and the container name. The name carries both halves because the two pairs differ in server as well as environment, so a file says which machine it describes rather than leaving that to the value inside it, and the four in the table above are the four files. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. Selecting the file is how an environment is chosen: the file is sourced with `set -a`, so it overwrites a `DEPLOY_ROOT` the caller exported and setting that variable by hand does not switch anything. A named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root.
Each environment is one file under `~/.secrets/`, named `blog_<server>_<environment>.env`, selected with `ENV_FILE`, and holding the deploy root, the base URL, and the container name. The name carries both halves because the two pairs differ in server as well as environment, so a file says which machine it describes rather than leaving that to the value inside it, and the four in the table above are the four files. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. Selecting the file is how an environment is chosen. The script reads `DEPLOY_ROOT` from the file and overwrites one the caller exported. Setting that variable by hand therefore switches nothing. A named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root.

**The staging FQDN sits under the VPS wildcard deliberately.** `blog.vps.insanegenius.net` needs no new certificate and no new DNS record, and it keeps the staging name off the production domain.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ ENV_FILE=~/.secrets/blog_local_production.env deploy/make-release.sh "" "$RELEAS
EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL"
```

The deploy root and the base URL come from a file per environment in `~/.secrets/`, named `blog_<server>_<environment>.env`, copied from [example.env][env-example] and selected with `ENV_FILE`. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. The real files live on the host, never in this checkout.
The deploy root and the base URL come from a file per environment in `~/.secrets/`, named `blog_<server>_<environment>.env`, copied from [example.env][env-example] and selected with `ENV_FILE`. Make each one mode 600, since `make-release.sh` refuses a file that group or other can read. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. The real files live on the host, never in this checkout.

## 3rd Party Tools <!-- omit from toc -->

Expand Down
10 changes: 5 additions & 5 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL"
The deploy root and the base URL are the only host-specific values, and they pair per
environment. Copy [`example.env`][example-env] to `~/.secrets/blog_local_production.env`, which is the
file read when `ENV_FILE` is unset, and add `~/.secrets/blog_<server>_<environment>.env` for each further
environment. A single environment therefore needs `~/.secrets/blog_local_production.env` and nothing
environment. `chmod 600` each one, since the script refuses a file that group or other can read. A single environment therefore needs `~/.secrets/blog_local_production.env` and nothing
else, since a differently named file is read only when `ENV_FILE` names it. The real files
live on the host, in `~/.secrets/`, never in this checkout. CI passes the base URL and a scratch
bundle path explicitly instead, then uploads the bundle to the host's deploy root:
Expand All @@ -75,7 +75,7 @@ bundle path explicitly instead, then uploads the bundle to the host's deploy roo
SITE_BASE_URL=<base-url> deploy/make-release.sh <bundle-path> <release-id>
```

**That command-prefix form is CI-only.** A local run whose default environment file exists sources it after the command-prefix assignment and overwrites it, since `set -a` overwrites a value the caller exported first. Locally, select the environment through `ENV_FILE` instead, per the table below.
**That command-prefix form is CI-only.** A local run whose default environment file exists reads it after the command-prefix assignment and overwrites it. A value read from the file replaces one the caller exported. Locally, select the environment through `ENV_FILE` instead, per the table below.

**One file per environment, named `~/.secrets/blog_<server>_<environment>.env`, selected by `ENV_FILE`.**
Both halves are spelled out, so a name says which machine it describes as well as which
Expand All @@ -95,8 +95,8 @@ to create that path here and asks for a local one to assemble a bundle into:
ENV_FILE=~/.secrets/blog_vps_staging.env deploy/make-release.sh /path/to/bundle
```

Selecting the file is the only way to switch environments. The file is sourced with `set -a`,
which exports every assignment in it and **overwrites** a variable the caller exported first, so
Selecting the file is the only way to switch environments. The script reads `DEPLOY_ROOT`,
`DEPLOY_SSH_HOST`, and `SITE_BASE_URL` from it and **overwrites** each one the caller exported, so
`DEPLOY_ROOT=... deploy/make-release.sh` does not do what it looks like. The first argument
still wins, because it is read after the file. A named file that does not exist is a hard
failure rather than a fall-through to the ambient environment, since on a host running two sites
Expand All @@ -113,7 +113,7 @@ that reason.

| Variable | Effect |
| --- | --- |
| `ENV_FILE` | Which environment file to source. Defaults to `~/.secrets/blog_local_production.env`. |
| `ENV_FILE` | Which environment file to read. Defaults to `~/.secrets/blog_local_production.env`. |
| `DEPLOY_ROOT` | Fallback deploy root. The first argument wins. |
| `SITE_BASE_URL` | Overrides the site base URL. Bridged internally to `HUGO_BASEURL`, the name Hugo maps `HUGO_<KEY>` onto config natively. |
| `REQUIRE_BROTLI=1` | Fails rather than shipping gzip-only. CI sets this. |
Expand Down
21 changes: 10 additions & 11 deletions deploy/make-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ if [ "$#" -gt 2 ]; then
fi

REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib/fleet/sh/secrets.sh
. "$REPO/lib/fleet/sh/secrets.sh"

# The release upstream replaced `git whatchanged` with `git log`.
# Anything older restores nothing and exits 0, so this is the floor rather than a preference.
MTIME_MIN=2025.08

# The deploy root and the base URL are the only host-specific values, and they pair per environment.
# ENV_FILE selects the environment, because `set -a` overwrites a value the caller exported.
# ENV_FILE selects the environment, because the file's values replace the caller's, set or not.
# The first argument overrides the root, being read after this.
# Files are named ~/.secrets/blog_<server>_<environment>.env, both words spelled out, so the default names the environment it actually selects rather than being the one file whose name says nothing.
# The leading blog field disambiguates this repo's files in ~/.secrets, since other repos on the same host also write there.
Expand All @@ -59,17 +61,14 @@ case "$ENV_FILE" in
;;
*) ENV_FILE="$HOME/.secrets/$ENV_FILE" ;;
esac
if [ -f "$ENV_FILE" ]; then
# The default file is optional, because CI passes every value explicitly and reads no file.
# A named file is not, since a typo would fall through to the other environment's root.
if [ "$ENV_FILE" != "$DEFAULT_ENV_FILE" ] || [ "$(secret_verdict "$ENV_FILE")" != absent ]; then
require_secret "$ENV_FILE"
echo "==> environment: $ENV_FILE"
set -a
# shellcheck disable=SC1090,SC1091
. "$ENV_FILE"
set +a
elif [ "$ENV_FILE" != "$DEFAULT_ENV_FILE" ]; then
# The default file is optional, because CI passes every value explicitly and reads no file.
# A named file is not, since a typo would fall through to the other environment's root.
echo "environment file not found: $ENV_FILE" >&2
exit 1
DEPLOY_ROOT="$(read_env_value "$ENV_FILE" DEPLOY_ROOT)"
DEPLOY_SSH_HOST="$(read_env_value "$ENV_FILE" DEPLOY_SSH_HOST)"
SITE_BASE_URL="$(read_env_value "$ENV_FILE" SITE_BASE_URL)"
fi

# Hugo maps HUGO_<KEY> onto config natively, and only that name, so this is the one place SITE_BASE_URL becomes the name Hugo actually reads.
Expand Down
Loading
Loading