Skip to content

Releasing ​

A release is a version number, a changelog entry, and the same build the pipeline already runs.

While you work ​

bash
dqx changes --type patch "Stop assuming the www/ level"
dqx changes                        # what is pending

Each change is recorded as it is made, with its own type. Nobody writes a changelog at the end from memory, which is how changelogs become lists of commit subjects.

Rolling it ​

bash
dqx release --dry-run    # the version this would be, and the entry it would write
dqx release              # writes both

It reads the pending changes, works out whether that is a patch, a minor or a major, and writes package.json and CHANGELOG.md together. The version is derived from what changed rather than chosen, so the number means something.

Publishing ​

Push. That is the whole of it:

branchgoes to
stagingdqx.staging.dolphiq.nl
maindqx.dolphiq.nl

The pipeline lints, typechecks, runs the tests and the coverage gate, builds the documentation and the binaries for every target, and publishes them together — the site, latest.json, the binaries and install.sh on one host. dqx self-update reads latest.json from the same place the documentation lives, so a release cannot land in one place and not the other.

A binary built from staging reads staging's manifest. Somebody testing a release is not silently updated back to the previous one.

What the pipeline needs set ​

Per deployment environment, as secured variables — never in a file:

variableexample
DEST_SSH_USER_HOSTdqx@vps43.dolphiq.nl
DEST_MAIN_PATHdomains/dqx.dolphiq.nl
DEST_SSH_JUMP_USER_HOSTbitbucket@dolphiq.nl

The third is not optional here: these servers do not answer a hosted runner directly, so the connection goes through a bastion, and rsync takes the same route. Without it the runner spends four and a half minutes on a socket that will never answer. The key has to be authorised on the bastion and on the server behind it.

The options are written to a temporary ssh -F config rather than passed as flags, because a ProxyJump has to hold for the destination and not for the hop it goes through, and because rsync's -e splits on whitespace.

-F means only that file, so the key has to be named in it: Bitbucket injects the repository's key at /opt/atlassian/pipelines/agent/ssh/id_rsa_tmp, deliberately world-readable so any uid in the container can read it, and ssh refuses a key with permissions that open. It is copied into a 700 directory beside the config and removed when the script exits. Without that, ssh offers no key at all and the bastion answers Permission denied (publickey) — which reads like a problem on the far side and is not. DQX_DEPLOY_IDENTITY points somewhere else when it has to; a laptop's own ~/.ssh/config is included last, so everything above still wins.

Host keys are not verified

Matching every other pipeline here, the publish sets StrictHostKeyChecking no and UserKnownHostsFile /dev/null on both hops. A hosted runner has no known_hosts to check against, and without this both connections are refused unverified.

What it gives up is real: a machine placed in the middle of that path would not be noticed. It is a setting, not a fact — DQX_PUBLISH_STRICT_HOST_KEY=yes turns verification on, once both fingerprints are configured under the repository's SSH settings. Worth doing when somebody has ten minutes for it.

The same pair every other pipeline here uses. A path without a leading / is relative to the SSH user's home, which is how these hosts are laid out.

DEST_MAIN_PATH names the domain directory, not the docroot — the webserver serves public_html inside it — so publishing appends that:

domains/dqx.dolphiq.nl  →  dqx@vps43.dolphiq.nl:domains/dqx.dolphiq.nl/public_html/

Publishing to the path as given would report success and show nothing, which is why this is not left to chance. DQX_PUBLISH_SUBPATH changes the subdirectory; set it empty to publish to the path itself. DEPLOY_TARGET=user@host:/path bypasses all of it — it is used exactly as written, and is what an s3:// or gs:// target uses.

The addresses live in the committed .env.staging and .env.production; a URL is not a secret and one in the repository can be reviewed in a diff.

How a release lands ​

Not into the live directory. The tree goes up beside it, is checked where it landed, and only then takes its place:

public_html.incoming.20260830-193352   ← rsync writes here
  ↓ index.html, latest.json and every binary in the manifest are present and non-empty
public_html          → public_html.previous.20260830-193352
public_html.incoming → public_html

Two renames, so the moment in which neither is in place is a filesystem operation rather than a 134 MB transfer. A publish that dies halfway leaves the previous release serving and exits non-zero; what did arrive is left behind to be looked at.

The check runs on the far side on purpose. A complete tree on the runner says nothing about what survived the wire, and index.html alone would pass on a transfer that lost the manifest — dqx self-update asks for that file a second later.

Rolling back is the same two renames by hand:

bash
ssh dqx@vps43.dolphiq.nl
mv domains/dqx.dolphiq.nl/public_html domains/dqx.dolphiq.nl/public_html.bad
mv domains/dqx.dolphiq.nl/public_html.previous.20260830-193352 domains/dqx.dolphiq.nl/public_html

Old releases are cleaned up by the next build, not left to grow: the last two .previous directories are kept and older ones removed, and an .incoming left behind by a failed run survives exactly one further build. DQX_PUBLISH_KEEP changes how many. At 134 MB a release, that matters.

DQX_PUBLISH_SWAP=0 publishes straight into the live directory instead — which is what an s3:// or gs:// target does anyway, having no directories to rename.

Nothing on the far side is deleted by rsync itself. The tree that goes up is complete, and whatever else lives in that directory — the webserver's configuration, an .htaccess, a .well-known an ACME client renews from — is not this script's to remove. DQX_PUBLISH_DELETE=1 turns rsync --delete on for a target that is only ever ours.

Check what it would do without doing it:

bash
./scripts/publish.sh staging --dry-run
# would run: rsync -az site/.vitepress/dist/ dqx@vps43.dolphiq.nl:domains/dqx.staging.dolphiq.nl/public_html.incoming.20260830-193352/
# would check for: index.html latest.json dqx-darwin-arm64 dqx-darwin-x64
# would move:      …/public_html → …/public_html.previous.20260830-193352
#                  …/public_html.incoming.20260830-193352 → …/public_html
# would keep the last 2 previous release(s) and remove older ones

Which platforms ship ​

The pipeline runs on Linux and builds the Mac binaries there. bun takes the runtime for the target and appends the bundle; the result is a Mach-O arm64 carrying an ad-hoc, linker-signed signature, which is what Apple Silicon requires to run something at all. Verified by building one in the release image and running it on a Mac — config get, sites and generate all answered, embedded templates included.

Ad-hoc is not a Developer ID. A binary saved through a browser gets com.apple.quarantine and Gatekeeper refuses it; one fetched with curl does not, which is why the documented install is the curl line. Signing properly needs an Apple account, codesign with a Developer ID and notarisation — none of which can happen in a Linux container.

macOS only, for now — both architectures, so an Intel Mac is not stranded:

dqx-darwin-arm64
dqx-darwin-x64

Everyone here develops on one, and each target is a ~64 MB upload on every publish. Linux and Windows builds exist and are tested; they are simply not published yet.

Which platforms an environment publishes is its own setting, so staging and production can differ and the pipeline never changes:

ini
# .env.production
DQX_BUILD_TARGETS=darwin          # → darwin linux windows

A machine with no published build is told so plainly: dqx self-update answers "there is no build for this platform" rather than failing at a download.

What the documentation says about itself ​

The site is generated with the version in it — in the navigation, in the footer, and on the releases page. A documentation site that does not say which release it describes cannot be checked against the binary in somebody's hand.

The scaffold catalogue and the command list are generated too, from the registry and from the CLI. They cannot drift: a recipe that gains a service, or a command that is added, changes the page on the next build.

Setting up publishing ​

Split by what is and is not a secret:

where it liveswhy
addresses — DQX_PUBLIC_BASE.env.staging, .env.production, committeda URL is not a secret, and one in the repository can be reviewed in a diff
DEPLOY_TARGET and its keya secured variable per deployment environmentthese reach a server
your own overrides.env, gitignoredyours alone

The pipeline calls it by name — ./scripts/publish.sh staging — because the step already knows which environment it is. A command that says publish.sh staging tells a reader what it does; one that says publish.sh "$DEPLOY_TARGET" does not.

DEPLOY_TARGET decides how the files travel, by its shape:

shapewhat runs
user@host:/var/www/dqxrsync over ssh — add the key under Bitbucket's SSH keys
s3://bucket/prefixaws s3 sync --delete
gs://bucket/prefixgsutil rsync -d -r
/var/www/dqxrsync to a path the runner already has

DQX_PUBLIC_BASE is where those files will be readable from. latest.json holds absolute URLs, so dqx self-update needs no knowledge of where it was published — which is what lets one script serve both environments.

No credential is in the repository: an ssh key, an AWS profile or a service account belongs in the pipeline's secured variables.

bash
DEPLOY_TARGET=/tmp/try ./scripts/publish.sh staging --dry-run

It refuses rather than guessing: an unknown environment lists the ones that exist, and a missing DEPLOY_TARGET says it is a secured variable rather than something to put in a file.

dqx v1.1.0