From 0477d13b7475c7ed572bf609eb34a9727a0e7b36 Mon Sep 17 00:00:00 2001 From: Nathan Gillett Date: Sat, 30 May 2026 18:36:15 -0500 Subject: [PATCH 1/2] Restore v0.1 release pipelines for binaries and tap Re-add GoReleaser, Cosign signing, GitHub Releases, and Homebrew tap bump workflows plus release operator documentation. --- .github/workflows/release-binaries.yml | 138 ++++++++++ .github/workflows/release-build-sign.yml | 320 +++++++++++++++++++++++ .github/workflows/release-homebrew.yml | 108 ++++++++ .goreleaser.yaml | 96 +++++++ README.md | 2 +- docs/release.md | 53 ++++ scripts/render-homebrew-formulas.py | 152 +++++++++++ 7 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-binaries.yml create mode 100644 .github/workflows/release-build-sign.yml create mode 100644 .github/workflows/release-homebrew.yml create mode 100644 .goreleaser.yaml create mode 100644 docs/release.md create mode 100644 scripts/render-homebrew-formulas.py diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 0000000..0cd26d9 --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -0,0 +1,138 @@ +name: release binaries + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + release_ref: + description: Git ref to build for a dry run. + required: true + default: refs/heads/main + type: string + +permissions: + contents: write + id-token: write + +jobs: + build: + name: "IntentProof Release: Build Binaries" + runs-on: ubuntu-latest + outputs: + artifact_paths: ${{ steps.artifact-paths.outputs.artifact_paths }} + release_ref: ${{ steps.release.outputs.release_ref }} + release_version: ${{ steps.release.outputs.release_version }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_ref || github.ref }} + fetch-depth: 0 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Resolve release metadata + id: release + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + release_ref="${{ inputs.release_ref }}" + release_version="0.0.0-dryrun" + else + release_ref="${GITHUB_REF}" + release_version="${GITHUB_REF_NAME#v}" + fi + source_date_epoch="$(git log -1 --format=%ct)" + { + echo "release_ref=${release_ref}" + echo "release_version=${release_version}" + echo "source_date_epoch=${source_date_epoch}" + } >> "$GITHUB_OUTPUT" + + - uses: goreleaser/goreleaser-action@v7 + if: github.event_name != 'workflow_dispatch' + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ github.token }} + SOURCE_DATE_EPOCH: ${{ steps.release.outputs.source_date_epoch }} + + - uses: goreleaser/goreleaser-action@v7 + if: github.event_name == 'workflow_dispatch' + with: + distribution: goreleaser + version: "~> v2" + args: release --clean --snapshot --skip=publish + env: + SOURCE_DATE_EPOCH: ${{ steps.release.outputs.source_date_epoch }} + + - name: Collect binary artifacts + id: artifact-paths + run: | + set -euo pipefail + find dist -maxdepth 1 -type f \ + \( -name 'intentproof_*.tar.gz' -o -name 'intentproof_*.zip' \ + -o -name 'intentproof-verify_*.tar.gz' -o -name 'intentproof-verify_*.zip' \) \ + | sort > dist/release-artifacts.txt + test -s dist/release-artifacts.txt + { + echo "artifact_paths<> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v7 + with: + name: release-binary-artifacts + path: | + dist/release-artifacts.txt + dist/intentproof_*.tar.gz + dist/intentproof_*.zip + dist/intentproof-verify_*.tar.gz + dist/intentproof-verify_*.zip + + sign: + name: "IntentProof Release: Sign Binary Artifacts" + needs: build + uses: ./.github/workflows/release-build-sign.yml + with: + artifact_kind: binary + subject_name: intentproof-tools + release_version: ${{ needs.build.outputs.release_version }} + release_ref: ${{ needs.build.outputs.release_ref }} + artifact_paths: ${{ needs.build.outputs.artifact_paths }} + artifact_download_name: release-binary-artifacts + artifact_download_path: dist + sbom_extra_paths: . + attest_to_rekor: ${{ github.event_name != 'workflow_dispatch' }} + + publish-signing-metadata: + name: "IntentProof Release: Publish Binary Metadata" + needs: + - build + - sign + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + name: release-signing-metadata + path: release-signing-metadata + + - name: Upload signing metadata to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + mapfile -t files < <(find release-signing-metadata -type f | sort) + test "${#files[@]}" -gt 0 + gh release upload "$RELEASE_TAG" "${files[@]}" --clobber diff --git a/.github/workflows/release-build-sign.yml b/.github/workflows/release-build-sign.yml new file mode 100644 index 0000000..9f84265 --- /dev/null +++ b/.github/workflows/release-build-sign.yml @@ -0,0 +1,320 @@ +name: release build sign + +on: + workflow_call: + inputs: + artifact_kind: + required: true + type: string + subject_name: + required: true + type: string + release_version: + required: true + type: string + release_ref: + required: true + type: string + artifact_paths: + required: false + type: string + default: "" + artifact_download_name: + required: false + type: string + default: "" + artifact_download_path: + required: false + type: string + default: "." + npm_package_path: + required: false + type: string + default: "" + pypi_dist_dir: + required: false + type: string + default: "" + pypi_package_path: + required: false + type: string + default: "." + sbom_extra_paths: + required: false + type: string + default: "" + attest_to_rekor: + required: false + type: boolean + default: true + secrets: + npm_token: + required: false + pypi_api_token: + required: false + +jobs: + validate: + name: "IntentProof Release: Validate Inputs" + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate release signing inputs + env: + ARTIFACT_KIND: ${{ inputs.artifact_kind }} + ATTEST_TO_REKOR: ${{ inputs.attest_to_rekor }} + ARTIFACT_PATHS: ${{ inputs.artifact_paths }} + NPM_PACKAGE_PATH: ${{ inputs.npm_package_path }} + PYPI_DIST_DIR: ${{ inputs.pypi_dist_dir }} + RELEASE_REF: ${{ inputs.release_ref }} + run: | + set -euo pipefail + + case "$ARTIFACT_KIND" in + binary|npm|pypi|generic) ;; + *) + echo "artifact_kind must be one of binary, npm, pypi, generic" >&2 + exit 1 + ;; + esac + + semver_tag='^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' + dry_run_ref='^(refs/heads/[0-9A-Za-z._/-]+|[0-9a-fA-F]{40})$' + if [[ "$ATTEST_TO_REKOR" == "true" ]]; then + if [[ ! "$RELEASE_REF" =~ $semver_tag ]]; then + echo "release_ref must be a SemVer tag ref like refs/tags/v1.2.3 when attest_to_rekor=true" >&2 + exit 1 + fi + elif [[ ! "$RELEASE_REF" =~ $semver_tag && ! "$RELEASE_REF" =~ $dry_run_ref ]]; then + echo "dry-run release_ref must be a SemVer tag, refs/heads/*, or a full commit SHA" >&2 + exit 1 + fi + + case "$ARTIFACT_KIND" in + binary|generic) + test -n "$ARTIFACT_PATHS" || { echo "artifact_paths is required" >&2; exit 1; } + ;; + npm) + test -n "$NPM_PACKAGE_PATH" || { echo "npm_package_path is required" >&2; exit 1; } + ;; + pypi) + test -n "$PYPI_DIST_DIR" || { echo "pypi_dist_dir is required" >&2; exit 1; } + ;; + esac + + binary-generic: + name: "IntentProof Release: Sign Binary or Generic Artifact" + needs: validate + if: inputs.artifact_kind == 'binary' || inputs.artifact_kind == 'generic' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.release_ref }} + + - uses: actions/download-artifact@v8 + if: inputs.artifact_download_name != '' + with: + name: ${{ inputs.artifact_download_name }} + path: ${{ inputs.artifact_download_path }} + + - uses: sigstore/cosign-installer@v3 + + - uses: anchore/sbom-action/download-syft@v0 + + - name: Sign files and generate release metadata + env: + ARTIFACT_PATHS: ${{ inputs.artifact_paths }} + SBOM_EXTRA_PATHS: ${{ inputs.sbom_extra_paths }} + SUBJECT_NAME: ${{ inputs.subject_name }} + RELEASE_VERSION: ${{ inputs.release_version }} + ATTEST_TO_REKOR: ${{ inputs.attest_to_rekor }} + run: | + set -euo pipefail + tlog_args=() + if [[ "$ATTEST_TO_REKOR" != "true" ]]; then + tlog_args+=(--tlog-upload=false) + fi + + mkdir -p release-attestations + : > release-attestations/SHA256SUMS + + while IFS= read -r artifact; do + [[ -z "$artifact" ]] && continue + test -f "$artifact" || { echo "artifact not found: $artifact" >&2; exit 1; } + + ( + cd "$(dirname "$artifact")" + sha256sum "$(basename "$artifact")" + ) >> release-attestations/SHA256SUMS + cosign sign-blob --yes \ + "${tlog_args[@]}" \ + --bundle "${artifact}.sigstore.json" \ + --output-signature "${artifact}.sig" \ + "$artifact" + + syft packages "file:${artifact}" \ + -o "spdx-json=${artifact}.spdx.json" + + if [[ -n "$SBOM_EXTRA_PATHS" ]]; then + syft packages "$SBOM_EXTRA_PATHS" \ + -o "spdx-json=${artifact}.source.spdx.json" + fi + + cosign attest-blob --yes \ + "${tlog_args[@]}" \ + --type spdxjson \ + --predicate "${artifact}.spdx.json" \ + --bundle "${artifact}.sbom.sigstore.json" \ + "$artifact" + + artifact_sha="$(sha256sum "$artifact" | awk '{print $1}')" + ARTIFACT_PATH="$artifact" ARTIFACT_SHA="$artifact_sha" python3 - <<'PY' + import json + import os + from pathlib import Path + + artifact_path = os.environ["ARTIFACT_PATH"] + run_id = os.environ["GITHUB_RUN_ID"] + predicate = { + "builder": { + "id": ( + f"{os.environ['GITHUB_SERVER_URL']}/" + f"{os.environ['GITHUB_REPOSITORY']}/actions/runs/{run_id}" + ) + }, + "buildType": "https://github.com/IntentProof/release-build-sign", + "invocation": { + "parameters": { + "releaseVersion": os.environ["RELEASE_VERSION"], + "artifactPath": artifact_path, + "subjectName": os.environ["SUBJECT_NAME"], + } + }, + "metadata": { + "buildInvocationId": ( + f"{run_id}-{os.environ['GITHUB_RUN_ATTEMPT']}" + ) + }, + "materials": [ + { + "uri": f"file:{artifact_path}", + "digest": {"sha256": os.environ["ARTIFACT_SHA"]}, + } + ], + } + Path(f"{artifact_path}.intoto.jsonl").write_text( + json.dumps(predicate, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + PY + cosign attest-blob --yes \ + "${tlog_args[@]}" \ + --type slsaprovenance \ + --predicate "${artifact}.intoto.jsonl" \ + --bundle "${artifact}.intoto.sigstore.json" \ + "$artifact" + done <<< "$ARTIFACT_PATHS" + + cosign sign-blob --yes \ + "${tlog_args[@]}" \ + --bundle release-attestations/SHA256SUMS.sigstore.json \ + --output-signature release-attestations/SHA256SUMS.sig \ + release-attestations/SHA256SUMS + + - uses: actions/upload-artifact@v7 + with: + name: release-signing-metadata + path: | + **/*.sig + **/*.sigstore.json + **/*.spdx.json + **/*.intoto.jsonl + release-attestations/SHA256SUMS + + npm: + name: "IntentProof Release: Pack and Publish npm Package" + needs: validate + if: inputs.artifact_kind == 'npm' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.release_ref }} + - uses: actions/setup-node@v6 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + - name: Pack and publish with npm provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.npm_token }} + NPM_PACKAGE_PATH: ${{ inputs.npm_package_path }} + run: | + set -euo pipefail + cd "$NPM_PACKAGE_PATH" + npm pack + if [[ -n "${NODE_AUTH_TOKEN:-}" ]]; then + npm publish --provenance + else + echo "npm_token not provided; packed artifact only." + fi + + pypi: + name: "IntentProof Release: Build and Publish Python Distribution" + needs: validate + if: inputs.artifact_kind == 'pypi' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + PYPI_API_TOKEN: ${{ secrets.pypi_api_token }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.release_ref }} + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - uses: actions/download-artifact@v8 + if: inputs.artifact_download_name != '' + with: + name: ${{ inputs.artifact_download_name }} + path: ${{ inputs.pypi_dist_dir }} + + - name: Build and check Python distributions + env: + ARTIFACT_DOWNLOAD_NAME: ${{ inputs.artifact_download_name }} + PYPI_PACKAGE_PATH: ${{ inputs.pypi_package_path }} + PYPI_DIST_DIR: ${{ inputs.pypi_dist_dir }} + run: | + set -euo pipefail + python -m pip install --upgrade build twine + if [[ -z "$ARTIFACT_DOWNLOAD_NAME" ]]; then + rm -rf "$PYPI_DIST_DIR" + python -m build --outdir "$PYPI_DIST_DIR" "$PYPI_PACKAGE_PATH" + fi + test -d "$PYPI_DIST_DIR" + twine check "$PYPI_DIST_DIR"/* + echo "PyPI trusted publishing is expected to attach PEP 740 attestations." + + - uses: pypa/gh-action-pypi-publish@release/v1 + if: env.PYPI_API_TOKEN == '' + with: + packages-dir: ${{ inputs.pypi_dist_dir }} + attestations: true + + - uses: pypa/gh-action-pypi-publish@release/v1 + if: env.PYPI_API_TOKEN != '' + with: + packages-dir: ${{ inputs.pypi_dist_dir }} + password: ${{ secrets.pypi_api_token }} + attestations: true diff --git a/.github/workflows/release-homebrew.yml b/.github/workflows/release-homebrew.yml new file mode 100644 index 0000000..bb480f7 --- /dev/null +++ b/.github/workflows/release-homebrew.yml @@ -0,0 +1,108 @@ +name: release homebrew + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: GitHub Release tag to publish into the tap. + required: true + type: string + +permissions: + contents: read + +jobs: + bump-homebrew-tap: + name: "IntentProof Release: Bump Homebrew Tap" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Resolve release tag + id: release + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + release_tag="$INPUT_RELEASE_TAG" + else + release_tag="$EVENT_RELEASE_TAG" + fi + [[ "$release_tag" =~ ^v[0-9]+[.][0-9]+[.][0-9]+ ]] || { + echo "release_tag must start with a SemVer tag such as v1.2.3" >&2 + exit 1 + } + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + echo "branch=bump-intentproof-tools-${release_tag#v}" >> "$GITHUB_OUTPUT" + + - name: Require tap update token + env: + TAP_TOKEN: ${{ secrets.INTENTPROOF_HOMEBREW_TAP_TOKEN }} + run: | + set -euo pipefail + test -n "$TAP_TOKEN" || { + echo "INTENTPROOF_HOMEBREW_TAP_TOKEN is required to update the tap" >&2 + exit 1 + } + + - name: Checkout Homebrew tap + uses: actions/checkout@v6 + with: + repository: IntentProof/homebrew-tap + token: ${{ secrets.INTENTPROOF_HOMEBREW_TAP_TOKEN }} + path: homebrew-tap + fetch-depth: 0 + + - name: Render formulae from release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.release_tag }} + run: | + set -euo pipefail + for attempt in {1..30}; do + gh release view "$RELEASE_TAG" \ + --repo IntentProof/intentproof-tools \ + --json assets \ + > release-assets.json + if python3 ./scripts/render-homebrew-formulas.py \ + --release-tag "$RELEASE_TAG" \ + --assets-json release-assets.json \ + --output-dir homebrew-tap/Formula; then + exit 0 + fi + echo "Release assets are not complete yet; retrying ($attempt/30)." + sleep 20 + done + echo "Timed out waiting for signed release assets for $RELEASE_TAG" >&2 + exit 1 + + - name: Open tap pull request + env: + GH_TOKEN: ${{ secrets.INTENTPROOF_HOMEBREW_TAP_TOKEN }} + RELEASE_TAG: ${{ steps.release.outputs.release_tag }} + TAP_BRANCH: ${{ steps.release.outputs.branch }} + run: | + set -euo pipefail + cd homebrew-tap + git switch -c "$TAP_BRANCH" + if [[ -z "$(git status --porcelain -- Formula)" ]]; then + echo "Homebrew formulae are already current for $RELEASE_TAG." + exit 0 + fi + git add Formula/intentproof.rb Formula/intentproof-verify.rb + git \ + -c user.name="IntentProof Release Bot" \ + -c user.email="release-bot@intentproof.io" \ + commit -s -m "Bump IntentProof formulae to ${RELEASE_TAG#v}" + git push -u origin "$TAP_BRANCH" + gh pr create \ + --repo IntentProof/homebrew-tap \ + --base main \ + --head "$TAP_BRANCH" \ + --title "Bump IntentProof formulae to ${RELEASE_TAG#v}" \ + --body "Updates the IntentProof Homebrew formulae to GitHub Release $RELEASE_TAG." diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7d21c54 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,96 @@ +version: 2 + +project_name: intentproof-tools + +before: + hooks: + - go mod download + +builds: + - id: intentproof + main: ./cmd/intentproof + binary: intentproof + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + flags: + - -trimpath + ldflags: + - >- + -s -w -buildid= + -X github.com/intentproof/intentproof-tools/pkg/buildinfo.Version={{ .Version }} + -X github.com/intentproof/intentproof-tools/pkg/buildinfo.Commit={{ .Commit }} + -X github.com/intentproof/intentproof-tools/pkg/buildinfo.Date={{ .Date }} + + - id: intentproof-verify + main: ./cmd/intentproof-verify + binary: intentproof-verify + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + flags: + - -trimpath + ldflags: + - >- + -s -w -buildid= + -X github.com/intentproof/intentproof-tools/pkg/buildinfo.Version={{ .Version }} + -X github.com/intentproof/intentproof-tools/pkg/buildinfo.Commit={{ .Commit }} + -X github.com/intentproof/intentproof-tools/pkg/buildinfo.Date={{ .Date }} + +archives: + - id: intentproof + ids: + - intentproof + name_template: >- + intentproof_{{ .Version }}_{{ .Os }}_{{ .Arch }} + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + + - id: intentproof-verify + ids: + - intentproof-verify + name_template: >- + intentproof-verify_{{ .Version }}_{{ .Os }}_{{ .Arch }} + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + +checksum: + name_template: SHA256SUMS + algorithm: sha256 + +release: + github: + owner: IntentProof + name: intentproof-tools + draft: false + prerelease: auto + mode: replace + +changelog: + disable: true diff --git a/README.md b/README.md index 35a6eeb..4c62cb6 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ related packages). brew tap IntentProof/tap && brew install intentproof intentproof-verify ``` -Release binaries and Cosign checks: see +Release flow (`v*` tags): [release.md](docs/release.md). Cosign verification: [counterparty-verification.md](docs/counterparty-verification.md). ## Build and test diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..4439dcf --- /dev/null +++ b/docs/release.md @@ -0,0 +1,53 @@ +# v0.1.0 release flow + +Release automation is tag-driven: bump the version in the repo, merge, tag +`v*`, then CI publishes artifacts. + +## intentproof-tools (binaries + Homebrew) + +1. Ensure `main` passes CI and `SPEC_REF` matches the spec tuple. +2. Tag on `intentproof-tools`: + + ```bash + git tag v0.1.0 + git push origin v0.1.0 + ``` + +3. Workflows (see [`release-signing.md`](release-signing.md)): + - `release-binaries.yml` — GoReleaser archives on GitHub Releases + - `release-homebrew.yml` — bumps `IntentProof/homebrew-tap` when the release + is published (requires `INTENTPROOF_HOMEBREW_TAP_TOKEN`) + +Dry-run binaries without publishing: + +```bash +gh workflow run release-binaries.yml -f release_ref=refs/heads/main +``` + +## intentproof-spec + +1. Tag `v0.1.0` on `intentproof-spec` after `npm test` passes on `main`. +2. `release-spec.yml` regenerates the integrity manifest, Cosign-signs it, and + uploads the spec release bundle (requires `SPEC_INTEGRITY_PRIVATE_KEY`). + +## SDK registries + +| Repo | Registry | Workflow | Version file | +|------|----------|----------|--------------| +| `intentproof-sdk-node` | npm | `release-npm.yml` | `package.json` | +| `intentproof-sdk-python` | PyPI | `release-pypi.yml` | `pyproject.toml` | +| `intentproof-sdk-go` | Go module (tag) | `release-go.yml` | tag only | + +Tag ref must match the package version (`v` + SemVer). Release jobs run the +same conformance tests as CI (pinned spec via `intentproof-tools` `SPEC_REF`). + +npm and PyPI publish use OIDC trusted publishers; configure registry tokens only +for dry-runs that opt into legacy auth. + +## Order for a coordinated `v0.1.0` tuple + +1. Spec tag (if the integrity manifest or schemas changed). +2. Tools tag (verifier binaries + tap bump). +3. SDK tags (npm / PyPI / Go module tag). + +Update spec `pins.v1.json` / matrix when normative spec content or SDK SHAs move. diff --git a/scripts/render-homebrew-formulas.py b/scripts/render-homebrew-formulas.py new file mode 100644 index 0000000..979bd3b --- /dev/null +++ b/scripts/render-homebrew-formulas.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Render Homebrew formulae from GitHub Release asset metadata.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +REPO = "IntentProof/intentproof-tools" +HOMEPAGE = f"https://github.com/{REPO}" + +TOOLS = { + "intentproof": { + "class_name": "Intentproof", + "desc": "Local loop command-line tool for signed execution proofs", + "test": 'assert_match "Usage: intentproof ", shell_output("#{bin}/intentproof 2>&1", 1)', + }, + "intentproof-verify": { + "class_name": "IntentproofVerify", + "desc": "IntentProof offline verifier command-line tool", + "test": ( + 'assert_match "Usage of intentproof-verify", ' + 'shell_output("#{bin}/intentproof-verify --help 2>&1", 1)' + ), + }, +} + +ARCHES = { + "arm": "arm64", + "intel": "amd64", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--release-tag", required=True) + parser.add_argument("--assets-json", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + return parser.parse_args() + + +def load_assets(path: Path) -> dict[str, dict[str, str]]: + data = json.loads(path.read_text(encoding="utf-8")) + assets = data.get("assets", data) + by_name: dict[str, dict[str, str]] = {} + for asset in assets: + name = asset["name"] + digest = asset.get("digest") or "" + by_name[name] = { + "sha256": digest.removeprefix("sha256:") if digest.startswith("sha256:") else "", + "url": asset["url"], + } + return by_name + + +def asset(by_name: dict[str, dict[str, str]], name: str) -> dict[str, str]: + try: + metadata = by_name[name] + except KeyError as exc: + raise ValueError(f"release asset not found: {name}") from exc + if not metadata["sha256"]: + raise ValueError(f"release asset {name} is missing a sha256 digest") + return metadata + + +def render_resource(name: str, metadata: dict[str, str], indent: str) -> list[str]: + return [ + f'{indent}resource "{name}" do', + f'{indent} url "{metadata["url"]}"', + f'{indent} sha256 "{metadata["sha256"]}"', + f"{indent}end", + ] + + +def render_formula(tool: str, release_tag: str, assets: dict[str, dict[str, str]]) -> str: + version = release_tag.removeprefix("v") + config = TOOLS[tool] + lines = [ + "# frozen_string_literal: true", + "", + 'require_relative "../lib/intentproof_formula_helpers"', + "", + f"class {config['class_name']} < Formula", + " include IntentproofFormulaHelpers", + "", + f' desc "{config["desc"]}"', + f' homepage "{HOMEPAGE}"', + f' version "{version}"', + ' license "Apache-2.0"', + "", + ' depends_on "cosign" => :build', + " depends_on :macos", + "", + " on_macos do", + ] + + arch_items = list(ARCHES.items()) + for index, (block_name, goarch) in enumerate(arch_items): + archive_name = f"{tool}_{version}_darwin_{goarch}.tar.gz" + archive = asset(assets, archive_name) + signature = asset(assets, f"{archive_name}.sig") + sigstore = asset(assets, f"{archive_name}.sigstore.json") + lines.extend( + [ + f" on_{block_name} do", + f' url "{archive["url"]}"', + f' sha256 "{archive["sha256"]}"', + "", + ] + ) + lines.extend(render_resource("signature", signature, " ")) + lines.append("") + lines.extend(render_resource("sigstore", sigstore, " ")) + lines.append(" end") + if index != len(arch_items) - 1: + lines.append("") + + lines.extend( + [ + " end", + "", + " def install", + " verify_intentproof_artifact!", + f' bin.install "{tool}"', + " end", + "", + " test do", + f" {config['test']}", + " end", + "end", + "", + ] + ) + return "\n".join(lines) + + +def main() -> int: + args = parse_args() + assets = load_assets(args.assets_json) + args.output_dir.mkdir(parents=True, exist_ok=True) + for tool in sorted(TOOLS): + (args.output_dir / f"{tool}.rb").write_text( + render_formula(tool, args.release_tag, assets), + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From aa3d6ca4a477edab3875cfa6e72a719bf1580274 Mon Sep 17 00:00:00 2001 From: Nathan Gillett Date: Sat, 30 May 2026 18:41:43 -0500 Subject: [PATCH 2/2] Drop automated Homebrew tap job from release restore Publish binaries only; document manual tap bumps and actual package SemVers instead of assuming v0.1.0 everywhere. --- .github/workflows/release-homebrew.yml | 108 ------------------------- README.md | 3 +- docs/release.md | 99 ++++++++++++++++------- 3 files changed, 71 insertions(+), 139 deletions(-) delete mode 100644 .github/workflows/release-homebrew.yml diff --git a/.github/workflows/release-homebrew.yml b/.github/workflows/release-homebrew.yml deleted file mode 100644 index bb480f7..0000000 --- a/.github/workflows/release-homebrew.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: release homebrew - -on: - release: - types: [published] - workflow_dispatch: - inputs: - release_tag: - description: GitHub Release tag to publish into the tap. - required: true - type: string - -permissions: - contents: read - -jobs: - bump-homebrew-tap: - name: "IntentProof Release: Bump Homebrew Tap" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Resolve release tag - id: release - env: - EVENT_NAME: ${{ github.event_name }} - EVENT_RELEASE_TAG: ${{ github.event.release.tag_name }} - INPUT_RELEASE_TAG: ${{ inputs.release_tag }} - run: | - set -euo pipefail - if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - release_tag="$INPUT_RELEASE_TAG" - else - release_tag="$EVENT_RELEASE_TAG" - fi - [[ "$release_tag" =~ ^v[0-9]+[.][0-9]+[.][0-9]+ ]] || { - echo "release_tag must start with a SemVer tag such as v1.2.3" >&2 - exit 1 - } - echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" - echo "branch=bump-intentproof-tools-${release_tag#v}" >> "$GITHUB_OUTPUT" - - - name: Require tap update token - env: - TAP_TOKEN: ${{ secrets.INTENTPROOF_HOMEBREW_TAP_TOKEN }} - run: | - set -euo pipefail - test -n "$TAP_TOKEN" || { - echo "INTENTPROOF_HOMEBREW_TAP_TOKEN is required to update the tap" >&2 - exit 1 - } - - - name: Checkout Homebrew tap - uses: actions/checkout@v6 - with: - repository: IntentProof/homebrew-tap - token: ${{ secrets.INTENTPROOF_HOMEBREW_TAP_TOKEN }} - path: homebrew-tap - fetch-depth: 0 - - - name: Render formulae from release assets - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.release.outputs.release_tag }} - run: | - set -euo pipefail - for attempt in {1..30}; do - gh release view "$RELEASE_TAG" \ - --repo IntentProof/intentproof-tools \ - --json assets \ - > release-assets.json - if python3 ./scripts/render-homebrew-formulas.py \ - --release-tag "$RELEASE_TAG" \ - --assets-json release-assets.json \ - --output-dir homebrew-tap/Formula; then - exit 0 - fi - echo "Release assets are not complete yet; retrying ($attempt/30)." - sleep 20 - done - echo "Timed out waiting for signed release assets for $RELEASE_TAG" >&2 - exit 1 - - - name: Open tap pull request - env: - GH_TOKEN: ${{ secrets.INTENTPROOF_HOMEBREW_TAP_TOKEN }} - RELEASE_TAG: ${{ steps.release.outputs.release_tag }} - TAP_BRANCH: ${{ steps.release.outputs.branch }} - run: | - set -euo pipefail - cd homebrew-tap - git switch -c "$TAP_BRANCH" - if [[ -z "$(git status --porcelain -- Formula)" ]]; then - echo "Homebrew formulae are already current for $RELEASE_TAG." - exit 0 - fi - git add Formula/intentproof.rb Formula/intentproof-verify.rb - git \ - -c user.name="IntentProof Release Bot" \ - -c user.email="release-bot@intentproof.io" \ - commit -s -m "Bump IntentProof formulae to ${RELEASE_TAG#v}" - git push -u origin "$TAP_BRANCH" - gh pr create \ - --repo IntentProof/homebrew-tap \ - --base main \ - --head "$TAP_BRANCH" \ - --title "Bump IntentProof formulae to ${RELEASE_TAG#v}" \ - --body "Updates the IntentProof Homebrew formulae to GitHub Release $RELEASE_TAG." diff --git a/README.md b/README.md index 4c62cb6..db752e2 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ related packages). brew tap IntentProof/tap && brew install intentproof intentproof-verify ``` -Release flow (`v*` tags): [release.md](docs/release.md). Cosign verification: +Release flow (`v*` tags, manual Homebrew tap): [release.md](docs/release.md). +Cosign verification: [counterparty-verification.md](docs/counterparty-verification.md). ## Build and test diff --git a/docs/release.md b/docs/release.md index 4439dcf..6085626 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,53 +1,92 @@ -# v0.1.0 release flow +# Release flow (tag → registry) -Release automation is tag-driven: bump the version in the repo, merge, tag -`v*`, then CI publishes artifacts. +Release automation is tag-driven: set the version in each repo, merge, push a +`v*` tag that matches that version, then CI publishes artifacts. -## intentproof-tools (binaries + Homebrew) +**Contract milestone** names (for example the v0.1 local verifier contract in +`docs/v0.1-local-contract.md`) describe the *spec/tools behavior freeze*, not +the SemVer on every package. Package versions below are what registries and tags +use today. + +## Current package versions (2026-05) + +| Repo | Version source | Published as | +|------|----------------|--------------| +| `intentproof-tools` | Git tag only | GitHub Release archives (`intentproof`, `intentproof-verify`) | +| `intentproof-spec` | `package.json` `1.0.0` | GitHub Release spec bundle + signed manifest | +| `intentproof-sdk-node` | `package.json` `0.2.0` | npm `@intentproof/sdk` | +| `intentproof-sdk-python` | `pyproject.toml` `0.1.0` | PyPI `intentproof` | +| `intentproof-sdk-go` | Git tag only | Go module via proxy | +| `homebrew-tap` | Formula `version` field | Manual bump after tools release | + +Matrix `*_version` labels in `intentproof-spec` are tuple bookkeeping strings; +they may differ from registry SemVer until you align them on a release bump. + +## intentproof-tools (binaries) 1. Ensure `main` passes CI and `SPEC_REF` matches the spec tuple. -2. Tag on `intentproof-tools`: +2. Tag with the verifier release SemVer (example first stable: `v0.2.0` if that + is the chosen tools line — not necessarily `v0.1.0`): ```bash - git tag v0.1.0 - git push origin v0.1.0 + git tag v0.2.0 + git push origin v0.2.0 ``` -3. Workflows (see [`release-signing.md`](release-signing.md)): - - `release-binaries.yml` — GoReleaser archives on GitHub Releases - - `release-homebrew.yml` — bumps `IntentProof/homebrew-tap` when the release - is published (requires `INTENTPROOF_HOMEBREW_TAP_TOKEN`) +3. `release-binaries.yml` runs GoReleaser and Cosign signing (see + [`release-signing.md`](release-signing.md)). -Dry-run binaries without publishing: +Dry-run without publishing a GitHub Release: ```bash gh workflow run release-binaries.yml -f release_ref=refs/heads/main ``` +### Homebrew tap (manual, no tools repo secret) + +Automated tap PRs previously used `INTENTPROOF_HOMEBREW_TAP_TOKEN`; that is +**not** part of the restored slim pipeline. After a tools GitHub Release exists: + +1. In `homebrew-tap`, run (or adapt) the renderer from a tools checkout: + + ```bash + gh release view v0.2.0 --repo IntentProof/intentproof-tools --json assets > /tmp/assets.json + python3 scripts/render-homebrew-formulas.py \ + --release-tag v0.2.0 \ + --assets-json /tmp/assets.json \ + --output-dir Formula + ``` + +2. Open a PR on `IntentProof/homebrew-tap` with the formula changes. + ## intentproof-spec -1. Tag `v0.1.0` on `intentproof-spec` after `npm test` passes on `main`. -2. `release-spec.yml` regenerates the integrity manifest, Cosign-signs it, and - uploads the spec release bundle (requires `SPEC_INTEGRITY_PRIVATE_KEY`). +Tag must match `package.json` (currently `1.0.0` → `v1.0.0`): -## SDK registries +```bash +git tag v1.0.0 && git push origin v1.0.0 +``` -| Repo | Registry | Workflow | Version file | -|------|----------|----------|--------------| -| `intentproof-sdk-node` | npm | `release-npm.yml` | `package.json` | -| `intentproof-sdk-python` | PyPI | `release-pypi.yml` | `pyproject.toml` | -| `intentproof-sdk-go` | Go module (tag) | `release-go.yml` | tag only | +`release-spec.yml` needs `SPEC_INTEGRITY_PRIVATE_KEY` for manifest signing on +tag releases. + +## SDK registries -Tag ref must match the package version (`v` + SemVer). Release jobs run the -same conformance tests as CI (pinned spec via `intentproof-tools` `SPEC_REF`). +| Repo | Tag example | Workflow | +|------|-------------|----------| +| `intentproof-sdk-node` | `v0.2.0` | `release-npm.yml` | +| `intentproof-sdk-python` | `v0.1.0` | `release-pypi.yml` | +| `intentproof-sdk-go` | `v0.2.0` (or chosen module tag) | `release-go.yml` | -npm and PyPI publish use OIDC trusted publishers; configure registry tokens only -for dry-runs that opt into legacy auth. +Release jobs use the same conformance gates as CI (pinned spec via tools +`SPEC_REF`). The workflow syncs npm version from the tag on pack; PyPI uses the +version in `pyproject.toml` — ensure it matches the tag before pushing. -## Order for a coordinated `v0.1.0` tuple +npm and PyPI use OIDC trusted publishers on tag pushes. -1. Spec tag (if the integrity manifest or schemas changed). -2. Tools tag (verifier binaries + tap bump). -3. SDK tags (npm / PyPI / Go module tag). +## Suggested first coordinated cut -Update spec `pins.v1.json` / matrix when normative spec content or SDK SHAs move. +1. Spec `v1.0.0` only if integrity manifest / schemas changed since last tuple. +2. Tools `v0.2.0` (or your chosen verifier SemVer) + manual Homebrew PR. +3. SDK tags matching each `package.json` / `pyproject.toml` / module tag. +4. Update `pins.v1.json` / matrix if normative spec content or SDK SHAs move.