diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1ed94a2d..5af11297e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,7 +124,7 @@ jobs: scripts/release/npm-wrapper-smoke.js|scripts/mobile-smoke.sh|scripts/check-provider-registry.py) heavy=true ;; - docs/*|*.md|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/scripts/agent-task-metadata.test.sh|.github/workflows/agent-task-labels.yml|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/release/install-dogfood.sh|scripts/release/install-dogfood.test.sh|scripts/release/prepare-release.sh|scripts/release/prepare-release.test.sh|scripts/check-coauthor-trailers.py|scripts/dev-cache.sh|scripts/dev-cache.test.sh|scripts/dev-cargo.sh|scripts/dev-test.sh) + docs/*|*.md|packaging/aur/*|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/scripts/agent-task-metadata.test.sh|.github/workflows/agent-task-labels.yml|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/release/install-dogfood.sh|scripts/release/install-dogfood.test.sh|scripts/release/prepare-release.sh|scripts/release/prepare-release.test.sh|scripts/check-coauthor-trailers.py|scripts/dev-cache.sh|scripts/dev-cache.test.sh|scripts/dev-cargo.sh|scripts/dev-test.sh) ;; *) heavy=true @@ -190,6 +190,7 @@ jobs: bash scripts/release/require-release-tag-checkout.test.sh bash scripts/release/validate-crate-publish-order.test.sh bash scripts/release/verify-remote-tag.test.sh + bash packaging/aur/render.test.sh sh scripts/dev-cache.test.sh bash .github/scripts/update-homebrew-tap.test.sh node .github/scripts/release-workflows.test.js diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 7e1f51237..c5ba6fbd6 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -402,6 +402,8 @@ jobs: run: | node --test scripts/release/assemble-release-assets.test.js node --test npm/codewhale/test/artifacts.test.js npm/codewhale/test/release-assets.test.js + - name: Render AUR metadata from candidate Linux archives + run: bash packaging/aur/render.sh release-assets "${RUNNER_TEMP}/codewhale-bin" - name: Smoke packed npm wrapper against candidate assets env: CODEWHALE_SMOKE_ASSETS_DIR: ${{ github.workspace }}/release-assets diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 179156b42..e24f0f605 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -422,6 +422,27 @@ Install into a NixOS module: --- +## Omarchy / AUR + +On Omarchy, install the prebuilt AUR package: + +```bash +omarchy pkg aur add codewhale-bin +codewhale --version +``` + +`codewhale-bin` packages the same checksum-pinned Linux release archives as the +other binary install paths and provides both `codewhale` and `codew`. It does +not carry a separate Codewhale version. Package updates arrive through +`omarchy update`; the in-app updater leaves the pacman-owned binary to Omarchy. + +The AUR update follows the matching Codewhale tag and release assets, so it may +appear after the GitHub release while its generated `PKGBUILD` and `.SRCINFO` +are validated. Release-maintainer instructions live in +[`packaging/aur/README.md`](../packaging/aur/README.md). + +--- + ## Homebrew The formula is `codewhale`. The tap GitHub repo is still diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index 3c07c914a..2df128d39 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -323,6 +323,27 @@ manifest omits a required row; or the assets predate the matching release workflow run. If the command fails, rerun or repair `release.yml`; do not publish Cargo or npm against stale assets. +## AUR / Omarchy Package + +`codewhale-bin` is a downstream package of the same Linux release, not a new +Codewhale semantic version. After the public asset gate above passes, render +its AUR metadata from the verified release directory: + +```bash +./packaging/aur/render.sh /path/to/release-assets /tmp/codewhale-bin +``` + +The renderer reads the workspace version and extracts the x64/arm64 archive +hashes only after both release checksum manifests agree with the actual files. +It emits no `SKIP` checksums or source-controlled per-release values. Follow +[`packaging/aur/README.md`](../packaging/aur/README.md) for the clean Arch build, +`.SRCINFO` comparison, and package-content checks. + +The GitHub release workflows only verify that the AUR metadata can be rendered +from their candidate assets. They do not publish to AUR. AUR publication is a +separate, explicitly authorized maintainer action after the matching tag and +assets are public. + ## npm Wrapper Release `release.yml` publishes `codewhale` through npm Trusted Publishing after the diff --git a/packaging/aur/PKGBUILD.template b/packaging/aur/PKGBUILD.template new file mode 100644 index 000000000..d8a16701a --- /dev/null +++ b/packaging/aur/PKGBUILD.template @@ -0,0 +1,40 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034,SC2154 +# makepkg consumes the metadata variables and defines CARCH/srcdir/pkgdir. +pkgname=codewhale-bin +pkgver=@PKGVER@ +pkgrel=@PKGREL@ +pkgdesc='Provider-neutral coding agent for the terminal (prebuilt binary)' +arch=('x86_64' 'aarch64') +url='https://codewhale.net' +license=('MIT') +depends=('glibc' 'gcc-libs' 'dbus') +provides=("codewhale=${pkgver}" 'codewhale-tui' 'deepseek' 'deepseek-tui') +conflicts=('codewhale' 'codewhale-tui' 'deepseek' 'deepseek-tui' 'deepseek-tui-bin') +replaces=('deepseek-tui-bin') +options=('!strip') + +_release_url="https://github.com/Hmbown/CodeWhale/releases/download/v${pkgver}" + +source=("LICENSE-${pkgver}::https://raw.githubusercontent.com/Hmbown/CodeWhale/v${pkgver}/LICENSE") +sha256sums=('@LICENSE_SHA256@') +source_x86_64=("${pkgname}-${pkgver}-x86_64.tar.gz::${_release_url}/codewhale-linux-x64.tar.gz") +sha256sums_x86_64=('@X86_64_SHA256@') +source_aarch64=("${pkgname}-${pkgver}-aarch64.tar.gz::${_release_url}/codewhale-linux-arm64.tar.gz") +sha256sums_aarch64=('@AARCH64_SHA256@') + +package() { + local release_arch + case "${CARCH}" in + x86_64) release_arch='x64' ;; + aarch64) release_arch='arm64' ;; + *) printf 'unsupported architecture: %s\n' "${CARCH}" >&2; return 1 ;; + esac + + install -Dm755 "${srcdir}/codewhale-linux-${release_arch}/codewhale" \ + "${pkgdir}/usr/bin/codewhale" + install -Dm755 "${srcdir}/codewhale-linux-${release_arch}/codew" \ + "${pkgdir}/usr/bin/codew" + install -Dm644 "${srcdir}/LICENSE-${pkgver}" \ + "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 000000000..8dbe0b626 --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,73 @@ +# AUR packaging for Codewhale + +`codewhale-bin` installs the prebuilt `codewhale` runtime and its `codew` +convenience command from the same Linux archives published for each Codewhale +release. It does not compile a fork or carry a separate Codewhale version. + +The AUR repository is a separate publication destination. This directory is +the upstream source of its `PKGBUILD` and `.SRCINFO`, but the files with those +final names are generated only after the matching release archives and +checksum manifests exist. Nothing here publishes to AUR. + +## Render a release update + +From a checkout whose `Cargo.toml` has the released `X.Y.Z` workspace version, +obtain the complete, verified `codewhale-release-assets` directory and run: + +```bash +./packaging/aur/render.sh /path/to/release-assets /tmp/codewhale-bin +``` + +The renderer: + +- reads `pkgver` from `[workspace.package]` rather than accepting a second + product version; +- requires the x64 and arm64 Linux archives under their canonical release + names; +- requires both release checksum manifests to agree with each other and with + the archive bytes; +- inserts real SHA-256 values for both archives and the tagged MIT license; +- rejects unresolved placeholders, `SKIP`, malformed archives, and non-empty + output directories; and +- compares the rendered `.SRCINFO` with `makepkg --printsrcinfo` when `makepkg` + is available. + +The initial AUR revision for each upstream release is `pkgrel=1`. A recipe-only +correction may use a higher Arch package revision without changing Codewhale's +semantic version: + +```bash +./packaging/aur/render.sh /path/to/release-assets /tmp/codewhale-bin 2 +``` + +Do not invent checksums before release artifacts exist, use `SKIP`, or copy +hashes from an older release. + +## Validate on Arch or Omarchy + +Review the generated files, then validate them in an unprivileged clean build +environment: + +```bash +cd /tmp/codewhale-bin +makepkg --verifysource +makepkg --printsrcinfo | cmp - .SRCINFO +makepkg --cleanbuild +namcap PKGBUILD codewhale-bin-*.pkg.tar.zst +``` + +Inspect the package contents before installation. They should contain +`/usr/bin/codewhale`, `/usr/bin/codew`, and the MIT license, with no legacy +third command. After a test install, verify both entrypoints report `X.Y.Z`. + +Publishing the generated files to the `codewhale-bin` AUR repository requires +separate authorization from an AUR maintainer. Regenerate `.SRCINFO` whenever +package metadata changes, and never publish this recipe before the matching +Codewhale tag, archives, and checksum manifests are public and verified. + +Arch's `PKGBUILD(5)` contract defines architecture-specific sources and +checksums, and the AUR requires `.SRCINFO` to accompany metadata changes: + +- +- +- diff --git a/packaging/aur/SRCINFO.template b/packaging/aur/SRCINFO.template new file mode 100644 index 000000000..8cf4641a1 --- /dev/null +++ b/packaging/aur/SRCINFO.template @@ -0,0 +1,30 @@ +pkgbase = codewhale-bin + pkgdesc = Provider-neutral coding agent for the terminal (prebuilt binary) + pkgver = @PKGVER@ + pkgrel = @PKGREL@ + url = https://codewhale.net + arch = x86_64 + arch = aarch64 + license = MIT + depends = glibc + depends = gcc-libs + depends = dbus + provides = codewhale=@PKGVER@ + provides = codewhale-tui + provides = deepseek + provides = deepseek-tui + conflicts = codewhale + conflicts = codewhale-tui + conflicts = deepseek + conflicts = deepseek-tui + conflicts = deepseek-tui-bin + replaces = deepseek-tui-bin + options = !strip + source = LICENSE-@PKGVER@::https://raw.githubusercontent.com/Hmbown/CodeWhale/v@PKGVER@/LICENSE + sha256sums = @LICENSE_SHA256@ + source_x86_64 = codewhale-bin-@PKGVER@-x86_64.tar.gz::https://github.com/Hmbown/CodeWhale/releases/download/v@PKGVER@/codewhale-linux-x64.tar.gz + sha256sums_x86_64 = @X86_64_SHA256@ + source_aarch64 = codewhale-bin-@PKGVER@-aarch64.tar.gz::https://github.com/Hmbown/CodeWhale/releases/download/v@PKGVER@/codewhale-linux-arm64.tar.gz + sha256sums_aarch64 = @AARCH64_SHA256@ + +pkgname = codewhale-bin diff --git a/packaging/aur/render.sh b/packaging/aur/render.sh new file mode 100755 index 000000000..d92fb04bc --- /dev/null +++ b/packaging/aur/render.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage: $0 RELEASE_ASSETS_DIR OUTPUT_DIR [PKGREL]" >&2 +} + +if [[ $# -lt 2 || $# -gt 3 ]]; then + usage + exit 2 +fi + +assets_dir="$1" +output_dir="$2" +pkgrel="${3:-1}" +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" + +if [[ ! -d "${assets_dir}" ]]; then + echo "release assets directory does not exist: ${assets_dir}" >&2 + exit 1 +fi +assets_dir="$(cd "${assets_dir}" && pwd)" + +if [[ -e "${output_dir}" && ! -d "${output_dir}" ]]; then + echo "output path is not a directory: ${output_dir}" >&2 + exit 1 +fi +mkdir -p "${output_dir}" +if [[ -n "$(find "${output_dir}" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then + echo "output directory must be empty: ${output_dir}" >&2 + exit 1 +fi +output_dir="$(cd "${output_dir}" && pwd)" + +workspace_version="$( + grep -E '^version = "' "${repo_root}/Cargo.toml" \ + | head -n 1 \ + | sed -E 's/^version = "([^"]+)".*/\1/' +)" +if [[ ! "${workspace_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "workspace version must be X.Y.Z, got: ${workspace_version:-}" >&2 + exit 1 +fi +if [[ ! "${pkgrel}" =~ ^[1-9][0-9]*(\.[1-9][0-9]*)?$ ]]; then + echo "PKGREL must be a positive integer or positive x.y value, got: ${pkgrel}" >&2 + exit 2 +fi + +artifact_manifest="${assets_dir}/codewhale-artifacts-sha256.txt" +bundle_manifest="${assets_dir}/codewhale-bundles-sha256.txt" +for manifest in "${artifact_manifest}" "${bundle_manifest}"; do + if [[ ! -f "${manifest}" ]]; then + echo "release assets are missing checksum manifest: ${manifest}" >&2 + exit 1 + fi +done + +sha256_file() { + local path="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${path}" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${path}" | awk '{print $1}' + else + echo "sha256sum or shasum is required" >&2 + return 1 + fi +} + +manifest_sha() { + local manifest="$1" + local asset="$2" + local matches match_count checksum + matches="$(awk -v asset="${asset}" '$2 == asset { print $1 }' "${manifest}")" + match_count="$(printf '%s\n' "${matches}" | awk 'NF { count++ } END { print count + 0 }')" + if [[ "${match_count}" -ne 1 ]]; then + echo "$(basename "${manifest}") must contain exactly one checksum for ${asset}" >&2 + return 1 + fi + checksum="$(printf '%s\n' "${matches}" | awk 'NF { print; exit }')" + if [[ ! "${checksum}" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "invalid checksum for ${asset} in $(basename "${manifest}"): ${checksum}" >&2 + return 1 + fi + printf '%s' "${checksum}" | tr 'A-F' 'a-f' +} + +verified_archive_sha() { + local asset="$1" + local archive="${assets_dir}/${asset}" + if [[ ! -f "${archive}" ]]; then + echo "release assets are missing ${asset}" >&2 + return 1 + fi + + local artifact_sha bundle_sha actual_sha + artifact_sha="$(manifest_sha "${artifact_manifest}" "${asset}")" + bundle_sha="$(manifest_sha "${bundle_manifest}" "${asset}")" + if [[ "${artifact_sha}" != "${bundle_sha}" ]]; then + echo "checksum manifests disagree for ${asset}" >&2 + return 1 + fi + actual_sha="$(sha256_file "${archive}")" + if [[ "${artifact_sha}" != "${actual_sha}" ]]; then + echo "release asset checksum mismatch for ${asset}" >&2 + return 1 + fi + + local release_arch="${asset#codewhale-linux-}" + release_arch="${release_arch%.tar.gz}" + local listing + listing="$(tar -tzf "${archive}")" + for entry in \ + "codewhale-linux-${release_arch}/codewhale" \ + "codewhale-linux-${release_arch}/codew"; do + if ! grep -Fqx "${entry}" <<<"${listing}"; then + echo "${asset} is missing required archive entry: ${entry}" >&2 + return 1 + fi + done + + printf '%s' "${actual_sha}" +} + +x86_64_sha="$(verified_archive_sha 'codewhale-linux-x64.tar.gz')" +aarch64_sha="$(verified_archive_sha 'codewhale-linux-arm64.tar.gz')" +license_sha="$(sha256_file "${repo_root}/LICENSE")" + +render_template() { + local source="$1" + local destination="$2" + local content + content="$(<"${source}")" + content="${content//@PKGVER@/${workspace_version}}" + content="${content//@PKGREL@/${pkgrel}}" + content="${content//@LICENSE_SHA256@/${license_sha}}" + content="${content//@X86_64_SHA256@/${x86_64_sha}}" + content="${content//@AARCH64_SHA256@/${aarch64_sha}}" + printf '%s\n' "${content}" > "${destination}" +} + +render_template "${repo_root}/packaging/aur/PKGBUILD.template" "${output_dir}/PKGBUILD" +render_template "${repo_root}/packaging/aur/SRCINFO.template" "${output_dir}/.SRCINFO" + +for rendered in "${output_dir}/PKGBUILD" "${output_dir}/.SRCINFO"; do + if grep -Eq '@(PKGVER|PKGREL|LICENSE_SHA256|X86_64_SHA256|AARCH64_SHA256)@|SKIP' "${rendered}"; then + echo "rendered AUR metadata contains an unresolved or insecure checksum token: ${rendered}" >&2 + exit 1 + fi +done +bash -n "${output_dir}/PKGBUILD" + +if command -v makepkg >/dev/null 2>&1; then + generated_srcinfo="$(mktemp)" + trap 'rm -f "${generated_srcinfo}"' EXIT + (cd "${output_dir}" && makepkg --printsrcinfo) > "${generated_srcinfo}" + if ! cmp -s "${generated_srcinfo}" "${output_dir}/.SRCINFO"; then + echo "rendered .SRCINFO does not match makepkg --printsrcinfo" >&2 + diff -u "${output_dir}/.SRCINFO" "${generated_srcinfo}" >&2 || true + exit 1 + fi +fi + +echo "Rendered codewhale-bin ${workspace_version}-${pkgrel} from verified release archives:" +echo " ${output_dir}/PKGBUILD" +echo " ${output_dir}/.SRCINFO" diff --git a/packaging/aur/render.test.sh b/packaging/aur/render.test.sh new file mode 100755 index 000000000..4ffae42e3 --- /dev/null +++ b/packaging/aur/render.test.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# shellcheck disable=SC1091,SC2034,SC2329 +# The test sources a generated PKGBUILD whose makepkg variables are dynamic. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/../.." && pwd)" +temp_root="$(mktemp -d)" +trap 'rm -rf "${temp_root}"' EXIT + +assets_dir="${temp_root}/release-assets" +stage_dir="${temp_root}/stage" +mkdir -p "${assets_dir}" "${stage_dir}" + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +for release_arch in x64 arm64; do + archive_root="codewhale-linux-${release_arch}" + mkdir -p "${stage_dir}/${archive_root}" + printf '#!/usr/bin/env sh\necho codewhale-%s\n' "${release_arch}" \ + > "${stage_dir}/${archive_root}/codewhale" + printf '#!/usr/bin/env sh\necho codew-%s\n' "${release_arch}" \ + > "${stage_dir}/${archive_root}/codew" + chmod 0755 \ + "${stage_dir}/${archive_root}/codewhale" \ + "${stage_dir}/${archive_root}/codew" + COPYFILE_DISABLE=1 tar -czf "${assets_dir}/${archive_root}.tar.gz" \ + -C "${stage_dir}" "${archive_root}" +done + +for manifest in codewhale-artifacts-sha256.txt codewhale-bundles-sha256.txt; do + { + printf '%s %s\n' \ + "$(sha256_file "${assets_dir}/codewhale-linux-x64.tar.gz")" \ + 'codewhale-linux-x64.tar.gz' + printf '%s %s\n' \ + "$(sha256_file "${assets_dir}/codewhale-linux-arm64.tar.gz")" \ + 'codewhale-linux-arm64.tar.gz' + } > "${assets_dir}/${manifest}" +done + +first_output="${temp_root}/first" +second_output="${temp_root}/second" +revision_output="${temp_root}/revision" +bash "${repo_root}/packaging/aur/render.sh" "${assets_dir}" "${first_output}" +bash "${repo_root}/packaging/aur/render.sh" "${assets_dir}" "${second_output}" +bash "${repo_root}/packaging/aur/render.sh" "${assets_dir}" "${revision_output}" 2 +cmp "${first_output}/PKGBUILD" "${second_output}/PKGBUILD" +cmp "${first_output}/.SRCINFO" "${second_output}/.SRCINFO" + +workspace_version="$( + grep -E '^version = "' "${repo_root}/Cargo.toml" \ + | head -n 1 \ + | sed -E 's/^version = "([^"]+)".*/\1/' +)" +grep -Fqx "pkgver=${workspace_version}" "${first_output}/PKGBUILD" +grep -Fqx "depends=('glibc' 'gcc-libs' 'dbus')" "${first_output}/PKGBUILD" +for dependency in glibc gcc-libs dbus; do + grep -Fqx $'\tdepends = '"${dependency}" "${first_output}/.SRCINFO" +done +grep -Fqx 'pkgrel=2' "${revision_output}/PKGBUILD" +grep -Fqx $'\tpkgrel = 2' "${revision_output}/.SRCINFO" +grep -Fq "/releases/download/v${workspace_version}/codewhale-linux-x64.tar.gz" \ + "${first_output}/.SRCINFO" +grep -Fq "/releases/download/v${workspace_version}/codewhale-linux-arm64.tar.gz" \ + "${first_output}/.SRCINFO" +if grep -R -Eq 'SKIP|@[A-Z0-9_]+@' "${first_output}"; then + echo "rendered metadata retained a placeholder or SKIP checksum" >&2 + exit 1 +fi + +for arch_case in 'x86_64:x64' 'aarch64:arm64'; do + carch="${arch_case%%:*}" + release_arch="${arch_case#*:}" + package_src="${temp_root}/package-src-${carch}" + package_root="${temp_root}/package-root-${carch}" + mkdir -p "${package_src}" "${package_root}" + tar -xzf "${assets_dir}/codewhale-linux-${release_arch}.tar.gz" -C "${package_src}" + cp "${repo_root}/LICENSE" "${package_src}/LICENSE-${workspace_version}" + ( + source "${first_output}/PKGBUILD" + install() { + local mode="${1#-Dm}" + command mkdir -p "$(dirname "$3")" + command cp "$2" "$3" + command chmod "${mode}" "$3" + } + CARCH="${carch}" + srcdir="${package_src}" + pkgdir="${package_root}" + package + ) + cmp \ + "${stage_dir}/codewhale-linux-${release_arch}/codewhale" \ + "${package_root}/usr/bin/codewhale" + cmp \ + "${stage_dir}/codewhale-linux-${release_arch}/codew" \ + "${package_root}/usr/bin/codew" + cmp "${repo_root}/LICENSE" \ + "${package_root}/usr/share/licenses/codewhale-bin/LICENSE" +done + +printf 'tampered\n' >> "${assets_dir}/codewhale-linux-x64.tar.gz" +if bash "${repo_root}/packaging/aur/render.sh" \ + "${assets_dir}" "${temp_root}/tampered-output" >/dev/null 2>&1; then + echo "renderer accepted a release archive that no longer matched its manifests" >&2 + exit 1 +fi + +echo "AUR render contract passed"