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
116 changes: 116 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ on:
GH_TOKEN:
description: Dedicated cross-repository token with write access to wrightkit/homebrew-tap
required: true
R2_ACCESS_KEY_ID:
description: Cloudflare R2 S3 API access key with write access to the release bucket
required: true
R2_SECRET_ACCESS_KEY:
description: Cloudflare R2 S3 API secret key with write access to the release bucket
required: true
CLOUDFLARE_ACCOUNT_ID:
description: Cloudflare account ID for the R2 S3 API endpoint
required: true

permissions:
contents: write
Expand Down Expand Up @@ -369,3 +378,110 @@ jobs:
run: >-
gh release edit "${RELEASE_TAG}" --draft=false --latest
--repo "${GITHUB_REPOSITORY}"

publish-r2:
name: Publish verified archives to R2
needs:
- upload-artifacts
- publish-release
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ inputs.tag }}
R2_BUCKET: wrightkit-release
R2_PUBLIC_BASE_URL: https://releases.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 }}
AWS_DEFAULT_REGION: auto
steps:
- name: Check R2 publication configuration
run: |
set -euo pipefail
aws --version

- name: Download verified release artifacts
uses: actions/download-artifact@v8
with:
pattern: wright-*
path: artifacts

- name: Publish immutable versioned objects, then advance latest
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
release_dir="${GITHUB_WORKSPACE}/release"
mkdir -p "${release_dir}"
for triple in x86_64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin x86_64-pc-windows-msvc; do
ext="tar.gz"
[[ "${triple}" == "x86_64-pc-windows-msvc" ]] && ext="zip"
archive="wright-${VERSION}-${triple}.${ext}"
test -f "artifacts/wright-${triple}/${archive}" || { echo "missing ${archive}" >&2; exit 1; }
test -f "artifacts/wright-${triple}/${archive}.sha256" || { echo "missing ${archive}.sha256" >&2; exit 1; }
cp "artifacts/wright-${triple}/${archive}" "${release_dir}/"
cp "artifacts/wright-${triple}/${archive}.sha256" "${release_dir}/"
done

put_immutable() {
local source="$1" key="$2" cache_control="$3" content_type="$4"
local existing
if aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${R2_ENDPOINT}" >/dev/null 2>&1; then
existing="${GITHUB_WORKSPACE}/existing-$(basename "${key}")"
aws s3api get-object \
--bucket "${R2_BUCKET}" \
--key "${key}" \
--endpoint-url "${R2_ENDPOINT}" \
"${existing}" >/dev/null
cmp --silent "${source}" "${existing}" || {
echo "error: immutable R2 object ${key} differs from this release artifact" >&2
exit 1
}
rm -f "${existing}"
return
fi
aws s3api put-object \
--bucket "${R2_BUCKET}" \
--key "${key}" \
--body "${source}" \
--if-none-match '*' \
--cache-control "${cache_control}" \
--content-type "${content_type}" \
--endpoint-url "${R2_ENDPOINT}" >/dev/null
}

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

for archive in "${release_dir}"/wright-*.tar.gz "${release_dir}"/wright-*.zip; do
[[ -e "${archive}" ]] || continue
checksum="${archive}.sha256"
expected_hash="$(awk 'NR == 1 { print $1 }' "${checksum}")"
actual_hash="$(sha256sum "${archive}" | awk 'NR == 1 { print $1 }')"
test "${actual_hash}" = "${expected_hash}"
name="$(basename "${archive}")"
for prefix in "releases/${VERSION}" latest; do
put_immutable "${archive}" "${prefix}/${name}" 'public, max-age=31536000, immutable' 'application/octet-stream'
put_immutable "${checksum}" "${prefix}/${name}.sha256" 'public, max-age=31536000, immutable' 'text/plain; charset=utf-8'
verify_public "${prefix}/${name}" "${archive}" 'max-age=31536000.*immutable'
verify_public "${prefix}/${name}.sha256" "${checksum}" 'max-age=31536000.*immutable'
done
done

printf '%s\n' "${VERSION}" > "${GITHUB_WORKSPACE}/latest-version"
aws s3api put-object \
--bucket "${R2_BUCKET}" \
--key latest/version \
--body "${GITHUB_WORKSPACE}/latest-version" \
--cache-control 'no-store, max-age=0' \
--content-type 'text/plain; charset=utf-8' \
--endpoint-url "${R2_ENDPOINT}" >/dev/null
verify_public latest/version "${GITHUB_WORKSPACE}/latest-version" 'no-store'
rm -f "${GITHUB_WORKSPACE}/latest-version"
73 changes: 56 additions & 17 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,15 @@ workflow is the single product release path:
tag and release commit to the reusable `release.yml` workflow.
4. The reusable workflow verifies the tag/revision and version identity, runs
`scripts/release.sh` and `scripts/verify-dist.py`, builds and smoke-tests the
native matrix, attaches archives/checksums/manifests to the draft, publishes
the Homebrew tap, and only then
marks the GitHub Release public.
native matrix, attaches archives/checksums/manifests to the draft, and marks
the GitHub Release public. It then publishes the exact verified archives and
checksums to R2 and independently updates the Homebrew tap.

A failure in any gate or required downstream stage leaves the same draft
A failure before the GitHub Release is published leaves the same draft
Release/tag available for a retry; it does not create a new product version.
If R2 publication fails afterward, the public GitHub Release remains the
canonical record but `latest/version` is not advanced. A retry reuses only
byte-identical immutable R2 objects and refuses any conflicting object.

### Creating a release

Expand Down Expand Up @@ -102,7 +105,36 @@ Each archive contains `wright` (`wright.exe`), `wright-lsp`
signing/notarization remains deferred. Package-manager distribution is
supported through the channels below.

### Installing from GitHub Releases
### R2 installer distribution

`install.sh` uses the WrightKit R2 custom domain by default. GitHub Releases
remain the canonical release record and provenance source; R2 contains exact
copies of the archives and checksum files that the completed GitHub Release
already exposes.

Pinned installs use immutable versioned objects:

```text
https://releases.wrightkit.dev/releases/<version>/wright-<version>-<target-triple>.<ext>
https://releases.wrightkit.dev/releases/<version>/wright-<version>-<target-triple>.<ext>.sha256
```

Latest installs first read `https://releases.wrightkit.dev/latest/version`,
then download the corresponding version-named archive and checksum from
`/latest/`. The release workflow uploads and publicly verifies every versioned
and latest 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.

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.
The workflow retrieves every public copy and checks its bytes and cache headers
before it advances `latest/version`.

### Manual GitHub Release fallback

The download URL is deterministic:

Expand Down Expand Up @@ -180,11 +212,18 @@ Configure these optional/required environment secrets:
* `GH_TOKEN` is a fine-grained token with write access to
`wrightkit/homebrew-tap`; it is required for automatic Homebrew tap updates.
* The workflow's built-in `GITHUB_TOKEN` updates the draft GitHub Release.
* `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.

## Supported installation channels

All channels consume the canonical GitHub Release archives above; none of
them rebuild Wright. Metadata lives under [`dist/`](dist/README.md), generated
All channels consume canonical released archives and none of them rebuild
Wright. `install.sh` consumes the R2 copies described above; the package
managers and Windows installer continue to consume GitHub Release archives.
Metadata lives under [`dist/`](dist/README.md), generated
by `scripts/update-dist-manifests.py`, and is regenerated by the release PR
maintenance step and again by the `package-manifests` job from the published per-target
checksums, then attached to the Release as
Expand All @@ -204,11 +243,11 @@ secret and the per-channel publication process and boundaries.

`install.sh` is the supported Unix installer: it detects the platform (with
explicit failures for unsupported OS/architecture combinations), resolves the
latest stable release by default or an exact `--version` on request,
downloads the archive and checksum, verifies the SHA-256 before extracting,
installs both binaries, and runs a post-install version smoke check. Its
functional behavior is covered by `scripts/test-install.sh` against a mock
release server on Linux and macOS CI.
latest stable release from the R2 `latest` route by default or an exact
`--version` from an immutable versioned route on request, downloads the archive
and checksum, verifies the SHA-256 before extracting, installs both binaries,
and runs a post-install version smoke check. Its functional behavior is covered
by `scripts/test-install.sh` against a mock R2 route on Linux and macOS CI.

`install.ps1` is the supported Windows x86_64 installer. It resolves the latest
stable release by default or an exact `-Version`, downloads the canonical ZIP
Expand All @@ -220,13 +259,13 @@ user `PATH` update instruction. Its functional behavior is covered by


Standalone installations are also updatable in place: `wright update`
consumes the same release artifacts and checksums (no `install.sh`
re-execution, no second release path), verifies the checksum before
continues to consume the canonical GitHub Release artifacts and checksums (no
`install.sh` re-execution, no second build path), verifies the checksum before
replacing `wright` and `wright-lsp`, and refuses to overwrite
package-manager-managed binaries. See [`docs/cli.md`](cli.md) for the
command contract and the `WRIGHT_INSTALL_BASE_URL`/`WRIGHT_API_URL`/
`WRIGHT_INSTALL_OS`/`WRIGHT_INSTALL_ARCH` test overrides it shares with
`install.sh`.
command contract and its `WRIGHT_INSTALL_BASE_URL`/`WRIGHT_API_URL`/
`WRIGHT_INSTALL_OS`/`WRIGHT_INSTALL_ARCH` test overrides. The installer has a
separate R2 route contract and only shares the platform overrides.

Package-manager availability is not instantaneous: the Homebrew tap is updated
automatically by the `publish-tap` job, while the Scoop bucket and WinGet
Expand Down
33 changes: 19 additions & 14 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
#!/usr/bin/env bash
# Wright installer for Linux and macOS (#108).
#
# Installs the standalone `wright` and `wright-lsp` binaries from the
# canonical Wright GitHub Release archives. It is a thin release-artifact
# installer, not a package manager or source-build frontend: it resolves the
# platform artifact, verifies the published SHA-256 checksum, extracts the two
# binaries, and smoke-checks the installed version.
# Installs the standalone `wright` and `wright-lsp` binaries from WrightKit's
# release distribution. GitHub Releases remains the canonical release record;
# this thin installer resolves the R2-backed release artifact, verifies its
# SHA-256 checksum, extracts the two binaries, and smoke-checks the installed
# version.
#
# Usage:
# install.sh # latest stable release into ~/.local/bin
# install.sh --version 0.1.0 # exact version (CI/agents)
# install.sh --dir ~/bin # custom installation directory
#
# Environment overrides (test/advanced hooks, not the primary interface):
# WRIGHT_INSTALL_BASE_URL base URL of release artifacts
# WRIGHT_API_URL URL used to resolve the latest release
# WRIGHT_INSTALL_BASE_URL base URL of the R2-backed release distribution
# WRIGHT_INSTALL_OS override OS detection (linux | darwin)
# WRIGHT_INSTALL_ARCH override CPU detection (x86_64 | aarch64)

set -euo pipefail

WRIGHT_INSTALL_BASE_URL="${WRIGHT_INSTALL_BASE_URL:-https://github.com/wrightkit/wright/releases/download}"
WRIGHT_API_URL="${WRIGHT_API_URL:-https://api.github.com/repos/wrightkit/wright/releases/latest}"
WRIGHT_INSTALL_BASE_URL="${WRIGHT_INSTALL_BASE_URL:-https://releases.wrightkit.dev}"

VERSION=""
INSTALL_DIR=""
TMP_DIR=""
VERSION_FROM_LATEST=false

usage() {
sed -n '2,11p' "$0" | sed 's/^# \?//'
Expand Down Expand Up @@ -115,16 +114,22 @@ fi

if [[ -z "$VERSION" ]]; then
echo "==> resolving latest stable release"
LATEST_JSON="$(curl -fsSL "$WRIGHT_API_URL" 2>/dev/null)" \
|| fail "could not resolve the latest release from $WRIGHT_API_URL (offline or rate-limited?); pin a version with --version"
VERSION="$(printf '%s' "$LATEST_JSON" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)"
VERSION_FROM_LATEST=true
VERSION="$(curl -fsSL "$WRIGHT_INSTALL_BASE_URL/latest/version" 2>/dev/null)" \
|| fail "could not resolve the latest release from $WRIGHT_INSTALL_BASE_URL/latest/version; pin a version with --version"
VERSION="$(printf '%s' "$VERSION" | tr -d '[:space:]')"
VERSION="${VERSION#v}"
[[ -n "$VERSION" ]] || fail "could not parse the latest release tag; pin a version with --version"
printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$' \
|| fail "could not parse the latest release version; pin a version with --version"
echo " latest: $VERSION"
fi

ARCHIVE="wright-$VERSION-$TARGET.tar.gz"
ARCHIVE_URL="$WRIGHT_INSTALL_BASE_URL/v$VERSION/$ARCHIVE"
if [[ "$VERSION_FROM_LATEST" == true ]]; then
ARCHIVE_URL="$WRIGHT_INSTALL_BASE_URL/latest/$ARCHIVE"
else
ARCHIVE_URL="$WRIGHT_INSTALL_BASE_URL/releases/$VERSION/$ARCHIVE"
fi
CHECKSUM_URL="$ARCHIVE_URL.sha256"
EXPECTED_DIR="wright-$VERSION-$TARGET"

Expand Down
18 changes: 13 additions & 5 deletions scripts/distribution_test_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,9 @@ def __enter__(self) -> "ReleaseFixture":
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.base = f"http://127.0.0.1:{self.server.server_port}/releases/download"
self.r2_base = f"http://127.0.0.1:{self.server.server_port}/r2"
self.metadata = self._generate_metadata()
latest = self.work / "repos" / "wrightkit" / "wright" / "releases" / "latest"
latest.parent.mkdir(parents=True, exist_ok=True)
latest.write_text(
f'{{"tag_name":"v{self.version}","draft":false,"prerelease":false}}\n'
)
self._stage_r2_distribution()
return self

def __exit__(self, exc_type, exc_value, traceback) -> None:
Expand Down Expand Up @@ -156,6 +153,17 @@ def _stage_artifact(self) -> Path:
archive.with_name(f"{archive.name}.sha256").write_text(f"{digest} {archive.name}\n")
return archive

def _stage_r2_distribution(self) -> None:
versioned = self.work / "r2" / "releases" / self.version
latest = self.work / "r2" / "latest"
versioned.mkdir(parents=True)
latest.mkdir(parents=True)
checksum = self.archive.with_name(f"{self.archive.name}.sha256")
for destination in (versioned, latest):
shutil.copy2(self.archive, destination / self.archive.name)
shutil.copy2(checksum, destination / checksum.name)
(latest / "version").write_text(f"{self.version}\n")

def _generate_metadata(self) -> Path:
generator = load_generator()
hashes = {key: "" for key in generator.TARGETS}
Expand Down
16 changes: 14 additions & 2 deletions scripts/test-distribution-install-sh.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ def main() -> None:
**os.environ,
"HOME": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"WRIGHT_INSTALL_BASE_URL": fixture.base,
"WRIGHT_API_URL": f"{fixture.base}/repos/wrightkit/wright/releases/latest",
"WRIGHT_INSTALL_BASE_URL": fixture.r2_base,
}
run(
CHANNEL,
Expand All @@ -52,6 +51,19 @@ def main() -> None:
base_env,
)
native_smoke(CHANNEL, install_dir / "wright", install_dir / "wright-lsp", version)
latest_install_dir = fixture.work / "latest-install"
run(
CHANNEL,
"install.sh latest installation",
["bash", str(ROOT / "install.sh"), "--dir", str(latest_install_dir)],
base_env,
)
native_smoke(
CHANNEL,
latest_install_dir / "wright",
latest_install_dir / "wright-lsp",
version,
)
print(f"{CHANNEL} distribution validation passed")


Expand Down
Loading
Loading