diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10df0da..fbdbbe9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 }} diff --git a/docs/adr/0019-stable-installer-bootstrap-distribution.md b/docs/adr/0019-stable-installer-bootstrap-distribution.md new file mode 100644 index 0000000..f5ee95a --- /dev/null +++ b/docs/adr/0019-stable-installer-bootstrap-distribution.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0770dd0..5c1a092 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) diff --git a/docs/release.md b/docs/release.md index f704a07..4fa04d1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 @@ -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: @@ -148,13 +169,13 @@ https://releases.wrightkit.dev/wright/releases//wright--/`. 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//`. 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. @@ -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: @@ -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 diff --git a/scripts/publish-r2.sh b/scripts/publish-r2.sh index dbae0a7..a10a2ca 100755 --- a/scripts/publish-r2.sh +++ b/scripts/publish-r2.sh @@ -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 @@ -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" @@ -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" || { @@ -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" @@ -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"