diff --git a/.github/scripts/release-checksums.sh b/.github/scripts/release-checksums.sh new file mode 100755 index 0000000..7bc68f3 --- /dev/null +++ b/.github/scripts/release-checksums.sh @@ -0,0 +1,75 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "usage: release-checksums.sh VERSION DIST_DIRECTORY" >&2 + exit 64 +fi + +VERSION="$1" +DIST_DIRECTORY="$2" + +case "$VERSION" in + ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.-]*|*..*|*.) + echo "release checksums: invalid version: $VERSION" >&2 + exit 64 + ;; +esac +if [ "${#VERSION}" -gt 64 ]; then + echo "release checksums: version is too long" >&2 + exit 64 +fi +if [ ! -d "$DIST_DIRECTORY" ]; then + echo "release checksums: dist directory does not exist: $DIST_DIRECTORY" >&2 + exit 66 +fi + +DIST_DIRECTORY="$(CDPATH='' cd -- "$DIST_DIRECTORY" && pwd -P)" +set -- \ + "Headless-${VERSION}-macos.zip" \ + "headless-${VERSION}-linux-amd64.tar.gz" \ + "headless-${VERSION}-linux-arm64.tar.gz" + +for asset in "$@"; do + path="$DIST_DIRECTORY/$asset" + if [ ! -f "$path" ] || [ -L "$path" ] || [ ! -s "$path" ]; then + echo "release checksums: missing or unsafe release asset: $asset" >&2 + exit 66 + fi +done + +checksum_files() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$@" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$@" + else + echo "release checksums: sha256sum or shasum is required" >&2 + exit 69 + fi +} + +verify_manifest() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c SHA256SUMS + else + shasum -a 256 -c SHA256SUMS + fi +} + +MANIFEST="$DIST_DIRECTORY/SHA256SUMS" +TEMP_MANIFEST="$(mktemp "$DIST_DIRECTORY/.SHA256SUMS.XXXXXX")" +trap '[ -z "$TEMP_MANIFEST" ] || rm -f "$TEMP_MANIFEST"' EXIT INT TERM + +( + cd "$DIST_DIRECTORY" + checksum_files "$@" +) > "$TEMP_MANIFEST" +chmod 0644 "$TEMP_MANIFEST" +mv -f "$TEMP_MANIFEST" "$MANIFEST" +TEMP_MANIFEST="" + +( + cd "$DIST_DIRECTORY" + verify_manifest +) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d57ba5..ff1654e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,10 +89,13 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: path: dist merge-multiple: true + - name: Generate and verify checksums + run: ./.github/scripts/release-checksums.sh "${{ needs.version.outputs.version }}" dist - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: @@ -115,4 +118,5 @@ jobs: dist/Headless-${{ needs.version.outputs.version }}-macos.zip dist/headless-${{ needs.version.outputs.version }}-linux-amd64.tar.gz dist/headless-${{ needs.version.outputs.version }}-linux-arm64.tar.gz + dist/SHA256SUMS fail_on_unmatched_files: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6f257..23e9574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Cutting that release is tracked in ### Added +- Tagged releases now include a verified `SHA256SUMS` manifest covering every + downloadable macOS and Linux package. - A single cross-engine conformance scenario now runs against real WKWebView and Chromium hosts, locking shared response shapes and declared capability errors without mirrored platform assertions. diff --git a/README.md b/README.md index 870c5eb..f8827e6 100644 --- a/README.md +++ b/README.md @@ -210,9 +210,17 @@ git tag v1.0.0 git push origin v1.0.0 ``` -Assets: macOS `Headless.app` zip, Linux amd64/arm64 tarballs. See the Actions -`Release` workflow and the release notes on each tag for install caveats -(Gatekeeper; Linux Chromium/FFmpeg). +Assets: macOS `Headless.app` zip, Linux amd64/arm64 tarballs, and +`SHA256SUMS`. Download the manifest beside the selected package and verify it +before installing: + +```sh +sha256sum --ignore-missing -c SHA256SUMS # Linux +shasum -a 256 --ignore-missing -c SHA256SUMS # macOS +``` + +See the Actions `Release` workflow and the release notes on each tag for +install caveats (Gatekeeper; Linux Chromium/FFmpeg). ## Tests diff --git a/apps/headless/Tests/release-checksums.sh b/apps/headless/Tests/release-checksums.sh new file mode 100755 index 0000000..80005bd --- /dev/null +++ b/apps/headless/Tests/release-checksums.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." +SCRIPT="../../.github/scripts/release-checksums.sh" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/headless-release-checksums.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT INT TERM + +VERSION="1.2.3-rc.1" +MACOS="Headless-${VERSION}-macos.zip" +AMD64="headless-${VERSION}-linux-amd64.tar.gz" +ARM64="headless-${VERSION}-linux-arm64.tar.gz" + +printf 'macOS package\n' > "$TEST_ROOT/$MACOS" +printf 'Linux amd64 package\n' > "$TEST_ROOT/$AMD64" +printf 'Linux arm64 package\n' > "$TEST_ROOT/$ARM64" + +"$SCRIPT" "$VERSION" "$TEST_ROOT" >/dev/null +test "$(wc -l < "$TEST_ROOT/SHA256SUMS" | tr -d ' ')" -eq 3 +if [ "$(uname -s)" = "Darwin" ]; then + test "$(stat -f %Lp "$TEST_ROOT/SHA256SUMS")" = "644" +else + test "$(stat -c %a "$TEST_ROOT/SHA256SUMS")" = "644" +fi +awk '{print $2}' "$TEST_ROOT/SHA256SUMS" > "$TEST_ROOT/names" +printf '%s\n' "$MACOS" "$AMD64" "$ARM64" > "$TEST_ROOT/expected-names" +cmp -s "$TEST_ROOT/names" "$TEST_ROOT/expected-names" + +if command -v sha256sum >/dev/null 2>&1; then + (cd "$TEST_ROOT" && sha256sum -c SHA256SUMS >/dev/null) +else + (cd "$TEST_ROOT" && shasum -a 256 -c SHA256SUMS >/dev/null) +fi + +cp "$TEST_ROOT/SHA256SUMS" "$TEST_ROOT/original-manifest" +"$SCRIPT" "$VERSION" "$TEST_ROOT" >/dev/null +cmp -s "$TEST_ROOT/SHA256SUMS" "$TEST_ROOT/original-manifest" + +rm "$TEST_ROOT/$ARM64" +if "$SCRIPT" "$VERSION" "$TEST_ROOT" >/dev/null 2>&1; then + echo "release checksum test: missing assets must fail" >&2 + exit 1 +fi +cmp -s "$TEST_ROOT/SHA256SUMS" "$TEST_ROOT/original-manifest" + +ln -s "$AMD64" "$TEST_ROOT/$ARM64" +if "$SCRIPT" "$VERSION" "$TEST_ROOT" >/dev/null 2>&1; then + echo "release checksum test: symlink assets must fail" >&2 + exit 1 +fi + +for invalid_version in '' '../1.2.3' '.1.2.3' '1.2.3/' '1..2' '1.2.3.' \ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; do + if "$SCRIPT" "$invalid_version" "$TEST_ROOT" >/dev/null 2>&1; then + echo "release checksum test: invalid version accepted: $invalid_version" >&2 + exit 1 + fi +done + +echo "Release checksum tests passed" diff --git a/apps/headless/test.sh b/apps/headless/test.sh index 65ea996..dd41443 100755 --- a/apps/headless/test.sh +++ b/apps/headless/test.sh @@ -2,6 +2,8 @@ set -euo pipefail cd "$(dirname "$0")" +./Tests/release-checksums.sh + for tool in swift swiftc; do if ! command -v "$tool" >/dev/null 2>&1; then if [[ "$(uname -s)" == "Darwin" ]]; then diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 766d6f2..e5e19ee 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -368,8 +368,10 @@ Owner-decided scope: package managers, no hosted service. `HP/ChromiumRuntime.swift:167-184`). - **E3.** ([#41](https://github.com/LockInTime/headless/issues/41)) Publish the Docker `production` image to GHCR on tag (release notes currently say "not published"); this is also the interim Windows story. -- **E4.** ([#42](https://github.com/LockInTime/headless/issues/42)) `SHA256SUMS` (+ consider cosign) for all release assets — the QA - evidence bundle already ships sums; releases don't. +- **E4.** ([#42](https://github.com/LockInTime/headless/issues/42)) ~~`SHA256SUMS` for all release assets — the QA evidence bundle already ships + sums; releases don't.~~ **Done:** the publish job requires all three named + regular package files, generates `SHA256SUMS` atomically, verifies it, and + attaches the manifest to the release. Cosign remains optional future work. - **E5.** ([#43](https://github.com/LockInTime/headless/issues/43)) npm wrapper package (binary download shim) for `npx` reach. - **E6.** ([#44](https://github.com/LockInTime/headless/issues/44)) Version unification + `headless --version` + CHANGELOG + release automation (architecture §12). `package.json` says 0.0.0, tags say 1.0.x,