diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76745a1..630a3ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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" diff --git a/docs/release.md b/docs/release.md index 1f8aca0..c749676 100644 --- a/docs/release.md +++ b/docs/release.md @@ -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 @@ -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//wright--. +https://releases.wrightkit.dev/releases//wright--..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: @@ -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 @@ -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 @@ -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 diff --git a/install.sh b/install.sh index 8771c0f..2a512ed 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,11 @@ #!/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 @@ -13,19 +13,18 @@ # 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/^# \?//' @@ -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" diff --git a/scripts/distribution_test_support.py b/scripts/distribution_test_support.py index c40d313..929bbb0 100644 --- a/scripts/distribution_test_support.py +++ b/scripts/distribution_test_support.py @@ -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: @@ -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} diff --git a/scripts/test-distribution-install-sh.py b/scripts/test-distribution-install-sh.py index ec082f5..83f20d7 100644 --- a/scripts/test-distribution-install-sh.py +++ b/scripts/test-distribution-install-sh.py @@ -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, @@ -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") diff --git a/scripts/test-install.sh b/scripts/test-install.sh index 4d90a79..f9c9094 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -39,7 +39,7 @@ EOF make_archive() { local root="$1" triple="$2" - local release="$root/v$VERSION" + local release="$root/releases/$VERSION" local dir="$release/wright-$VERSION-$triple" local archive="$release/wright-$VERSION-$triple.tar.gz" mkdir -p "$dir" @@ -66,16 +66,13 @@ wait_for_server() { for triple in x86_64-unknown-linux-gnu aarch64-apple-darwin x86_64-apple-darwin; do make_archive "$WORK/mock" "$triple" done - -mkdir -p "$WORK/mock/repos/wrightkit/wright/releases" -cat > "$WORK/mock/repos/wrightkit/wright/releases/latest" < "$WORK/mock/latest/version" python3 -m http.server "$PORT" --directory "$WORK/mock" >/dev/null 2>&1 & SERVER_PID=$! BASE_URL="http://127.0.0.1:$PORT" -API_URL="$BASE_URL/repos/wrightkit/wright/releases/latest" wait_for_server "$BASE_URL" # --- helpers ----------------------------------------------------------------- @@ -92,9 +89,7 @@ report() { } run_install() { - WRIGHT_INSTALL_BASE_URL="$BASE_URL" \ - WRIGHT_API_URL="$API_URL" \ - "$INSTALLER" "$@" + WRIGHT_INSTALL_BASE_URL="$BASE_URL" "$INSTALLER" "$@" } expect_success() { @@ -125,11 +120,11 @@ expect_failure() { expect_success "pinned install (native platform)" "$WORK/d1" --version "$VERSION" -expect_success "latest-release resolution" "$WORK/d2" +expect_success "latest release resolves without the GitHub API" "$WORK/d2" printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa %s\n' \ "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ - > "$WORK/mock/v$VERSION/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" + > "$WORK/mock/releases/$VERSION/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" if WRIGHT_INSTALL_OS=linux WRIGHT_INSTALL_ARCH=x86_64 \ run_install --dir "$WORK/d3" --version "$VERSION" >"$INSTALL_OUTPUT" 2>&1; then report "checksum mismatch is rejected before install" fail @@ -143,7 +138,7 @@ test ! -e "$WORK/d3/wright" \ || report "nothing installed after checksum failure" fail printf 'not-a-hash %s\n' "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ - > "$WORK/mock/v$VERSION/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" + > "$WORK/mock/releases/$VERSION/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" if WRIGHT_INSTALL_OS=linux WRIGHT_INSTALL_ARCH=x86_64 \ run_install --dir "$WORK/d3b" --version "$VERSION" >"$INSTALL_OUTPUT" 2>&1; then report "malformed checksum file is rejected" fail @@ -153,7 +148,7 @@ else || report "malformed checksum file is rejected" fail fi -(cd "$WORK/mock/v$VERSION" && shasum -a 256 "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ +(cd "$WORK/mock/releases/$VERSION" && shasum -a 256 "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ > "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256") expect_failure "unknown version fails with an actionable error" "does release" "$WORK/d4" \ @@ -200,7 +195,7 @@ else fi mkdir -p "$WORK/home" -if HOME="$WORK/home" WRIGHT_INSTALL_BASE_URL="$BASE_URL" WRIGHT_API_URL="$API_URL" \ +if HOME="$WORK/home" WRIGHT_INSTALL_BASE_URL="$BASE_URL" \ "$INSTALLER" --version "$VERSION" >/dev/null 2>&1 && test -x "$WORK/home/.local/bin/wright"; then report "default install directory (\$HOME/.local/bin)" ok @@ -210,7 +205,7 @@ fi # Archive-layout regression: an archive missing wright-lsp must fail cleanly. make_archive "$WORK/mock-broken" "x86_64-unknown-linux-gnu" -release="$WORK/mock-broken/v$VERSION" +release="$WORK/mock-broken/releases/$VERSION" rm -f "$release/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ "$release/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" dir="$release/wright-$VERSION-x86_64-unknown-linux-gnu" @@ -226,7 +221,7 @@ BROKEN_PID=$! BROKEN_BASE_URL="http://127.0.0.1:$((PORT + 1))" wait_for_server "$BROKEN_BASE_URL" if WRIGHT_INSTALL_OS=linux WRIGHT_INSTALL_ARCH=x86_64 \ - WRIGHT_INSTALL_BASE_URL="$BROKEN_BASE_URL" WRIGHT_API_URL="$API_URL" \ + WRIGHT_INSTALL_BASE_URL="$BROKEN_BASE_URL" \ "$INSTALLER" --dir "$WORK/d9" --version "$VERSION" >"$INSTALL_OUTPUT" 2>&1; then report "archive missing wright-lsp fails cleanly" fail else