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
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ jobs:
fi

publish-r2:
name: Publish ${{ needs.release-identity.outputs.channel }} archives to R2
name: Publish ${{ needs.release-identity.outputs.channel }} distribution to R2
needs:
- release-identity
- release-assets
Expand All @@ -638,6 +638,7 @@ jobs:
RELEASE_COMMIT: ${{ needs.release-identity.outputs.commit }}
R2_BUCKET: wrightkit-release
R2_PUBLIC_BASE_URL: https://releases.wrightkit.dev
R2_INSTALLER_PUBLIC_BASE_URL: https://install.wrightkit.dev
R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
Expand Down
69 changes: 69 additions & 0 deletions docs/adr/0019-stable-installer-bootstrap-distribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ADR-0019: Stable installer bootstrap distribution

- Status: Accepted
- Date: 2026-09-27
- Related: [ADR-0014](0014-namespaced-immutable-r2-artifacts.md),
[docs/release.md](../release.md)

## Context

The website build copied Wright's canonical installer files into its static
output. That made the website delivery layer responsible for the first
installer request and required it to synchronize and validate another
repository's release scripts. Wright already owns the scripts and the R2
release publication that those scripts use.

## Decision

- Wright's `install.sh` and `install.ps1` remain the canonical installer
sources. The stable release workflow publishes the exact files from its
release commit to `wright/install.sh` and `wright/install.ps1` in the
`wrightkit-release` R2 bucket.
- The public entrypoints are
`https://install.wrightkit.dev/wright/install.sh` and
`https://install.wrightkit.dev/wright/install.ps1`. The hostname is an R2
custom domain attached to the existing release bucket. The website only
displays these commands; it does not copy or publish the files.
- These two unversioned bootstrap objects are mutable and use
`Cache-Control: no-store, max-age=0` and
`Content-Type: text/plain; charset=utf-8`. After the completed GitHub Release
and verified immutable archive/checksum set, the stable publisher uploads
both scripts, fetches them over HTTP/1.1, compares exact bytes, and verifies
their cache and content-type headers before advancing `wright/latest/version`.
- Nightly publication does not change the stable bootstrap objects. The
scripts continue to resolve and download release archives from
`releases.wrightkit.dev`.

The immutable versioned archive/checksum paths and the version-pointer behavior
in ADR-0014 remain unchanged. The install custom domain serves the same public
bucket object keys; the installer script paths are the documented bootstrap
surface.

## Alternatives considered

- **Keep copying scripts through the website build:** rejected because it
assigns installer publication and request delivery to the consumer site.
- **Fetch scripts from a moving GitHub branch:** rejected because the stable
release workflow should publish the canonical files and verify the public
endpoint as part of Wright's distribution contract.

## Consequences

- The first install request and the binary download path have an explicit
Wright-owned release boundary.
- Stable scripts are updated only by stable releases, not by website builds or
nightly publication.
- Release publication requires the `install.wrightkit.dev` R2 custom domain to
be active and readable over HTTP/1.1.

## Compatibility impact

The documented install commands move from `wrightkit.dev` script paths to
`install.wrightkit.dev`. The old website-served script paths are no longer part
of the supported bootstrap contract.

## Scope boundaries

This decision does not change the contents or runtime behavior of either
installer, release archive naming, checksums, package-manager distribution, or
the immutable R2 artifact contract in ADR-0014.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ its audit date.
* [ADR-0016: Current-directory and directory project targets](0016-current-directory-and-directory-project-targets.md)
* [ADR-0017: Domain-intelligence query contract](0017-domain-intelligence-query-contract.md)
* [ADR-0018: Tests-first integration verification](0018-tests-first-integration-verification.md)
* [ADR-0019: Stable installer bootstrap distribution](0019-stable-installer-bootstrap-distribution.md)
* [Post-ADR-0010 decision inventory](post-0010-inventory.md)
58 changes: 40 additions & 18 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ Wright has two release channels and one shared native build workflow:
commits that change directly to `main`, and then builds and smoke-tests the
native matrix from that post-bump commit. It publishes the versioned GitHub
Release only after those artifacts and generated package-manager manifests
pass validation. The stable R2 objects and pointer are updated only after
the GitHub Release is complete.
pass validation. The stable R2 objects, installer scripts, and version
pointer are updated only after the GitHub Release is complete.

No workspace crate is published to crates.io. Every workspace package
explicitly sets `publish = false`, so Cargo package publication cannot become
Expand Down Expand Up @@ -133,11 +133,32 @@ supported through the channels below.

### R2 installer distribution

`install.sh` uses the WrightKit R2 custom domain by default. GitHub Releases
remain the canonical stable release record and package-manager source; R2 is
the HTTP installer/object-delivery channel and also carries the separate
nightly channel. The stable R2 objects are exact copies of the archives in the
completed GitHub Release.
GitHub Releases remain the canonical stable release record and
package-manager source; R2 is the HTTP installer/object-delivery channel and
also carries the separate nightly channel. The stable R2 archive objects are
exact copies of the archives in the completed GitHub Release.

The canonical `install.sh` and `install.ps1` files in this repository are
published by the stable release workflow as mutable bootstrap objects:

```text
https://install.wrightkit.dev/wright/install.sh
https://install.wrightkit.dev/wright/install.ps1
```

The publisher takes both files from the exact release commit, uploads them
after verifying the immutable archive/checksum set, and fetches them back over
HTTP/1.1 to compare their bytes and check their response headers. Each script
uses `Cache-Control: no-store, max-age=0` and
`Content-Type: text/plain; charset=utf-8`. The scripts are verified before the
stable `latest/version` pointer advances. Nightly publication does not change
these stable bootstrap objects.

`install.wrightkit.dev` is a Cloudflare R2 custom domain for the existing
`wrightkit-release` bucket. It exposes the bucket's public objects under that
host as well; the documented installer entrypoints use the `/wright/` keys.
The website repository displays these commands but does not copy or publish
the scripts.

Pinned installs use immutable versioned objects:

Expand All @@ -148,13 +169,13 @@ https://releases.wrightkit.dev/wright/releases/<version>/wright-<version>-<targe

Latest installs first read `https://releases.wrightkit.dev/wright/latest/version`,
then download the corresponding version-named archive and checksum from
`/releases/<version>/`. The release workflow publicly verifies every versioned
archive/checksum pair before writing that `latest/version` pointer, so the
installer cannot resolve a new version before its complete artifact set is
available. `latest/version` uses `Cache-Control: no-store`; all archive and
checksum paths are version-named and use long-lived immutable caching. This
avoids stale latest pointers without a separate Worker, API, or GitHub Releases
API lookup.
`/wright/releases/<version>/`. The release workflow publicly verifies every
versioned archive/checksum pair and both stable scripts before writing that
`latest/version` pointer, so the installer cannot resolve a new version before
its complete artifact set is available. The version pointer and bootstrap
scripts use `Cache-Control: no-store, max-age=0`; all archive and checksum
paths are version-named and use long-lived immutable caching. This avoids stale
latest pointers without a separate Worker, API, or GitHub Releases API lookup.

Versioned R2 objects are uploaded with `If-None-Match: *`; retries may reuse an
already-present object only after comparing its bytes to the release artifact.
Expand Down Expand Up @@ -199,7 +220,7 @@ Windows x86_64 users can install the canonical release ZIP with the first-party
PowerShell installer:

```powershell
irm https://raw.githubusercontent.com/wrightkit/wright/main/install.ps1 | iex
irm https://install.wrightkit.dev/wright/install.ps1 | iex
```

For a pinned version or custom user-writable directory:
Expand Down Expand Up @@ -249,9 +270,10 @@ Configure these optional/required environment secrets:
uploads its verified assets.
* `CLOUDFLARE_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, and
`R2_SECRET_ACCESS_KEY` grant the release workflow S3 API access to the
`wrightkit-release` bucket. The bucket must expose `releases.wrightkit.dev`
as its production custom domain before a release; the workflow verifies that
public route during publication.
`wrightkit-release` bucket. The bucket must expose both
`releases.wrightkit.dev` and `install.wrightkit.dev` as production custom
domains before stable publication; the workflow verifies the public routes
during publication.

## Supported installation channels

Expand Down
44 changes: 32 additions & 12 deletions scripts/publish-r2.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Publish verified stable or nightly archives to immutable R2 objects.
# Publish verified stable or nightly archives and the stable installer scripts to R2.

set -euo pipefail

Expand All @@ -15,8 +15,11 @@ set -euo pipefail
: "${ARTIFACTS_DIR:?ARTIFACTS_DIR is required}"

version="$RELEASE_VERSION"
installer_public_base_url=
case "$RELEASE_CHANNEL" in
stable)
: "${R2_INSTALLER_PUBLIC_BASE_URL:?R2_INSTALLER_PUBLIC_BASE_URL is required for stable releases}"
installer_public_base_url="$R2_INSTALLER_PUBLIC_BASE_URL"
object_prefix="wright/releases/$version"
pointer_key="wright/latest/version"
pointer_value="$version"
Expand Down Expand Up @@ -46,7 +49,8 @@ done

put_immutable() {
local source="$1" key="$2" cache_control="$3" content_type="$4"
local existing="$GITHUB_WORKSPACE/existing-$(basename "$key")"
local existing
existing="$GITHUB_WORKSPACE/existing-$(basename "$key")"
if aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$R2_ENDPOINT" >/dev/null 2>&1; then
aws s3api get-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$R2_ENDPOINT" "$existing" >/dev/null
cmp --silent "$source" "$existing" || {
Expand All @@ -62,15 +66,26 @@ put_immutable() {
}

verify_public() {
local key="$1" source="$2" cache_pattern="$3"
local downloaded="$GITHUB_WORKSPACE/downloaded-$(basename "$key")"
curl --fail --silent --show-error --location --output "$downloaded" "$R2_PUBLIC_BASE_URL/$key"
local base_url="$1" key="$2" source="$3" cache_pattern="$4" content_type_pattern="$5"
local downloaded
downloaded="$GITHUB_WORKSPACE/downloaded-$(basename "$key")"
curl --http1.1 --fail --silent --show-error --location --output "$downloaded" "$base_url/$key"
cmp --silent "$source" "$downloaded"
curl --fail --silent --show-error --head "$R2_PUBLIC_BASE_URL/$key" | \
curl --http1.1 --fail --silent --show-error --head "$base_url/$key" | \
grep --ignore-case --extended-regexp "^cache-control:.*$cache_pattern" >/dev/null
curl --http1.1 --fail --silent --show-error --head "$base_url/$key" | \
grep --ignore-case --extended-regexp "^content-type:.*$content_type_pattern" >/dev/null
rm -f "$downloaded"
}

put_mutable() {
local source="$1" key="$2" content_type="$3" base_url="$4"
aws s3api put-object --bucket "$R2_BUCKET" --key "$key" --body "$source" \
--cache-control 'no-store, max-age=0' --content-type "$content_type" \
--endpoint-url "$R2_ENDPOINT" >/dev/null
verify_public "$base_url" "$key" "$source" 'no-store.*max-age=0' "$content_type"
}

for archive in "$release_dir"/wright-*.tar.gz "$release_dir"/wright-*.zip; do
[[ -e "$archive" ]] || continue
checksum="$archive.sha256"
Expand All @@ -80,13 +95,18 @@ for archive in "$release_dir"/wright-*.tar.gz "$release_dir"/wright-*.zip; do
name="$(basename "$archive")"
put_immutable "$archive" "$object_prefix/$name" 'public, max-age=31536000, immutable' 'application/octet-stream'
put_immutable "$checksum" "$object_prefix/$name.sha256" 'public, max-age=31536000, immutable' 'text/plain; charset=utf-8'
verify_public "$object_prefix/$name" "$archive" 'max-age=31536000.*immutable'
verify_public "$object_prefix/$name.sha256" "$checksum" 'max-age=31536000.*immutable'
verify_public "$R2_PUBLIC_BASE_URL" "$object_prefix/$name" "$archive" 'max-age=31536000.*immutable' 'application/octet-stream'
verify_public "$R2_PUBLIC_BASE_URL" "$object_prefix/$name.sha256" "$checksum" 'max-age=31536000.*immutable' 'text/plain'
done

if [[ "$RELEASE_CHANNEL" == stable ]]; then
for script in install.sh install.ps1; do
source="$GITHUB_WORKSPACE/$script"
test -s "$source" || { echo "missing canonical $script" >&2; exit 1; }
put_mutable "$source" "wright/$script" 'text/plain; charset=utf-8' "$installer_public_base_url"
done
fi

pointer_file="$GITHUB_WORKSPACE/r2-$RELEASE_CHANNEL-pointer"
printf '%s\n' "$pointer_value" > "$pointer_file"
aws s3api put-object --bucket "$R2_BUCKET" --key "$pointer_key" --body "$pointer_file" \
--cache-control 'no-store, max-age=0' --content-type 'text/plain; charset=utf-8' \
--endpoint-url "$R2_ENDPOINT" >/dev/null
verify_public "$pointer_key" "$pointer_file" 'no-store'
put_mutable "$pointer_file" "$pointer_key" 'text/plain; charset=utf-8' "$R2_PUBLIC_BASE_URL"
Loading