diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml deleted file mode 100644 index 7584f5f..0000000 --- a/.github/workflows/bump-version.yml +++ /dev/null @@ -1,230 +0,0 @@ -name: Bump Version - -on: - workflow_dispatch: - inputs: - version: - description: > - Explicit version to set (e.g. 0.2.0). - Takes precedence over the bump level below. - required: false - type: string - bump: - description: > - Semver bump level — used only when no explicit version is provided above. - required: false - type: choice - options: - - patch - - minor - - major - default: patch - -jobs: - bump-version: - name: Bump version and push tag - runs-on: ubuntu-latest - permissions: - contents: write - actions: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.94" - - - name: Resolve new version - id: ver - run: | - EXPLICIT="${{ github.event.inputs.version }}" - BUMP="${{ github.event.inputs.bump }}" - - # Read current version from Cargo.toml - CURRENT=$(grep -m1 '^version = ' Cargo.toml | sed 's/version = "\(.*\)"/\1/') - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" - - if [ -n "$EXPLICIT" ]; then - NEW="$EXPLICIT" - else - case "$BUMP" in - major) NEW="$((MAJOR + 1)).0.0" ;; - minor) NEW="${MAJOR}.$((MINOR + 1)).0" ;; - patch) NEW="${MAJOR}.${MINOR}.$((PATCH + 1))" ;; - *) echo "Unknown bump level: $BUMP" && exit 1 ;; - esac - fi - - echo "current=${CURRENT}" >> "$GITHUB_OUTPUT" - echo "new=${NEW}" >> "$GITHUB_OUTPUT" - echo "tag=v${NEW}" >> "$GITHUB_OUTPUT" - echo "Bumping ${CURRENT} → ${NEW}" - - - name: Update version in Cargo.toml - run: | - VERSION="${{ steps.ver.outputs.new }}" - # Replace the first occurrence of version = "..." (the [package] version) - python3 - <<'EOF' - import re, sys - - version = "${{ steps.ver.outputs.new }}" - with open("Cargo.toml") as f: - content = f.read() - content = re.sub( - r'^version = "[^"]*"', - f'version = "{version}"', - content, - count=1, - flags=re.MULTILINE, - ) - with open("Cargo.toml", "w") as f: - f.write(content) - print(f"Updated Cargo.toml to {version}") - EOF - - - name: Update version in package manifests - run: | - VERSION="${{ steps.ver.outputs.new }}" - TAG="${{ steps.ver.outputs.tag }}" - - python3 - <[^<]*', f'{version}', nuspec) - with open("pkg/chocolatey/renderflow.nuspec", "w") as f: - f.write(nuspec) - - # Chocolatey install script - with open("pkg/chocolatey/tools/chocolateyinstall.ps1") as f: - ps1 = f.read() - ps1 = re.sub( - r'https://github.com/egohygiene/renderflow/releases/download/v[^/]+/renderflow-x86_64-pc-windows-msvc\.exe', - f'https://github.com/egohygiene/renderflow/releases/download/{tag}/renderflow-x86_64-pc-windows-msvc.exe', - ps1 - ) - ps1 = re.sub(r"(-Checksum64\s+)['\"][^'\"]*['\"]", r"\1'PLACEHOLDER_SHA256'", ps1) - with open("pkg/chocolatey/tools/chocolateyinstall.ps1", "w") as f: - f.write(ps1) - - # AUR PKGBUILD (stable) - with open("pkg/aur/renderflow/PKGBUILD") as f: - pkgbuild = f.read() - pkgbuild = re.sub(r'^pkgver=.*', f'pkgver={version}', pkgbuild, flags=re.MULTILINE) - pkgbuild = re.sub(r'^pkgrel=.*', 'pkgrel=1', pkgbuild, flags=re.MULTILINE) - pkgbuild = re.sub(r"^sha256sums=.*", "sha256sums=('PLACEHOLDER_SHA256')", pkgbuild, flags=re.MULTILINE) - with open("pkg/aur/renderflow/PKGBUILD", "w") as f: - f.write(pkgbuild) - - print(f"Updated all package manifests to {version}") - EOF - - - name: Update Cargo.lock package version only - run: | - python3 - <<'EOF' - import re - import sys - - version = "${{ steps.ver.outputs.new }}" - with open("Cargo.lock") as f: - lines = f.readlines() - - in_package = False - found_renderflow = False - updated = False - - for i, line in enumerate(lines): - stripped = line.strip() - if stripped == "[[package]]": - in_package = True - found_renderflow = False - continue - if in_package and stripped.startswith('name = "'): - found_renderflow = re.match(r'^name\s*=\s*"renderflow"$', stripped) is not None - continue - if in_package and found_renderflow and stripped.startswith('version = "'): - line_lstrip = line.lstrip() - prefix = line[: len(line) - len(line_lstrip)] - newline = "\n" if line.endswith("\n") else "" - lines[i] = f'{prefix}version = "{version}"{newline}' - updated = True - break - - if not updated: - print("Failed to update renderflow package version in Cargo.lock", file=sys.stderr) - sys.exit(1) - - with open("Cargo.lock", "w") as f: - f.writelines(lines) - print(f"Updated Cargo.lock renderflow package version to {version}") - EOF - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Commit version bump - run: | - git add Cargo.toml Cargo.lock \ - Formula/renderflow.rb \ - pkg/scoop/renderflow.json \ - pkg/chocolatey/renderflow.nuspec \ - pkg/chocolatey/tools/chocolateyinstall.ps1 \ - pkg/aur/renderflow/PKGBUILD - if git diff --staged --quiet; then - echo "Nothing changed — version may already be ${{ steps.ver.outputs.new }}" - exit 0 - fi - git commit -m "chore(release): bump version to ${{ steps.ver.outputs.tag }}" - - - name: Create annotated tag - run: | - git tag -a "${{ steps.ver.outputs.tag }}" \ - -m "chore(release): release ${{ steps.ver.outputs.tag }}" - - - name: Push commit and tag - run: | - git push origin ${{ github.event.repository.default_branch }} - git push origin "${{ steps.ver.outputs.tag }}" - - - name: Dispatch release workflow - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # GITHUB_TOKEN pushes do not automatically trigger other workflows. - # Explicitly dispatch the release workflow so the release pipeline runs. - gh workflow run release.yml \ - --repo "${{ github.repository }}" \ - --ref "${{ steps.ver.outputs.tag }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9690b4b..757c212 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,771 +1,383 @@ -name: Release +name: Release integration candidate -# Triggered automatically when bump-version pushes a version tag. -# Because GITHUB_TOKEN pushes do not trigger other workflows, bump-version -# explicitly dispatches this workflow via `gh workflow run` after pushing the -# tag. The workflow_dispatch trigger below enables that code path. -# You can also run this workflow manually from the Actions tab for any tag. +# Run after the version/metadata PR is reviewed and merged. Create a NEW +# annotated tag at the reviewed main commit, then dispatch on the tag ref: +# gh workflow run release.yml --ref v0.3.0-rc.1 --field expected_commit=<40-hex-sha> +# Tag pushes alone never publish a release; the historical v0.2.1 tag is not reused. on: - push: - tags: - - 'v*' workflow_dispatch: + inputs: + expected_commit: + description: "Full reviewed main commit SHA to which the new tag resolves" + required: true + type: string concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false jobs: - # ── 1. Changelog & GitHub Release ──────────────────────────────────────── - - generate-changelog: - name: Generate changelog and create GitHub Release - runs-on: ubuntu-latest - concurrency: - group: changelog-${{ github.repository }} - cancel-in-progress: false + candidate: + name: Verify, attest, and publish Linux x86_64 candidate + runs-on: ubuntu-24.04 permissions: + actions: read + artifact-metadata: write contents: write + id-token: write + attestations: write + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + EXPECTED_COMMIT: ${{ inputs.expected_commit }} + RELEASE_TARGET: x86_64-unknown-linux-gnu + RELEASE_ASSETS: release-assets steps: - - name: Checkout repository (main branch) - uses: actions/checkout@v4 + - name: Check out the exact tag + uses: actions/checkout@v7 with: - ref: ${{ github.event.repository.default_branch }} - token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.ref }} fetch-depth: 0 + persist-credentials: false - - name: Fetch tags - run: git fetch --tags - - - name: Install git-cliff - uses: taiki-e/install-action@v2 - with: - tool: git-cliff - - - name: Generate full changelog - run: git cliff -o CHANGELOG.md - - - name: Generate release notes - run: git cliff --latest --strip header -o RELEASE_NOTES.md - - - name: Commit changelog - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - if git diff --staged --quiet; then - echo "Changelog already up to date, no commit needed." - else - git commit -m "chore(release): update changelog for ${{ github.ref_name }}" - git push origin ${{ github.event.repository.default_branch }} - fi - - - name: Upload release notes artifact - uses: actions/upload-artifact@v4 - with: - name: release-notes - path: RELEASE_NOTES.md - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - body_path: RELEASE_NOTES.md - - lint-release: - name: Lint before release - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.94" - components: clippy - - - name: Lint (clippy) - run: cargo clippy --workspace -- -D warnings - - # ── 2a. Release binaries (all platforms) ───────────────────────────────── - - build-binaries: - name: Build binary (${{ matrix.target }}) - runs-on: ${{ matrix.runner }} - needs: [generate-changelog, lint-release] - permissions: - contents: write - - strategy: - fail-fast: false - matrix: - include: - # ── Linux x86_64 ────────────────────────────────────────────────── - - target: x86_64-unknown-linux-musl - artifact_name: renderflow-x86_64-unknown-linux-musl - binary: target/x86_64-unknown-linux-musl/release/renderflow - runner: ubuntu-latest - use_cross: true - - target: x86_64-unknown-linux-gnu - artifact_name: renderflow-x86_64-unknown-linux-gnu - binary: target/x86_64-unknown-linux-gnu/release/renderflow - runner: ubuntu-latest - use_cross: true - # ── Linux aarch64 ───────────────────────────────────────────────── - - target: aarch64-unknown-linux-musl - artifact_name: renderflow-aarch64-unknown-linux-musl - binary: target/aarch64-unknown-linux-musl/release/renderflow - runner: ubuntu-latest - use_cross: true - - target: aarch64-unknown-linux-gnu - artifact_name: renderflow-aarch64-unknown-linux-gnu - binary: target/aarch64-unknown-linux-gnu/release/renderflow - runner: ubuntu-latest - use_cross: true - # ── Linux ARMv7 ─────────────────────────────────────────────────── - - target: armv7-unknown-linux-musleabihf - artifact_name: renderflow-armv7-unknown-linux-musleabihf - binary: target/armv7-unknown-linux-musleabihf/release/renderflow - runner: ubuntu-latest - use_cross: true - # ── Linux i686 ──────────────────────────────────────────────────── - - target: i686-unknown-linux-musl - artifact_name: renderflow-i686-unknown-linux-musl - binary: target/i686-unknown-linux-musl/release/renderflow - runner: ubuntu-latest - use_cross: true - # ── Windows ─────────────────────────────────────────────────────── - - target: x86_64-pc-windows-gnu - artifact_name: renderflow-x86_64-pc-windows-gnu.exe - binary: target/x86_64-pc-windows-gnu/release/renderflow.exe - runner: ubuntu-latest - use_cross: true - - target: x86_64-pc-windows-msvc - artifact_name: renderflow-x86_64-pc-windows-msvc.exe - binary: target/x86_64-pc-windows-msvc/release/renderflow.exe - runner: windows-latest - use_cross: false - # ── macOS ───────────────────────────────────────────────────────── - - target: x86_64-apple-darwin - artifact_name: renderflow-x86_64-apple-darwin - binary: target/x86_64-apple-darwin/release/renderflow - runner: macos-13 - use_cross: false - - target: aarch64-apple-darwin - artifact_name: renderflow-aarch64-apple-darwin - binary: target/aarch64-apple-darwin/release/renderflow - runner: macos-latest - use_cross: false - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Install Python for release tooling and documentation + uses: actions/setup-python@v5 with: - toolchain: "1.94" - targets: ${{ matrix.target }} - - - name: Install cross - if: matrix.use_cross - uses: taiki-e/install-action@v2 - with: - tool: cross - - - name: Build release binary (cross) - if: matrix.use_cross - run: cross build --target ${{ matrix.target }} --release - - - name: Build release binary (cargo) - if: "!matrix.use_cross" - run: cargo build --target ${{ matrix.target }} --release + python-version: "3.12" - - name: Copy binary to artifact name - shell: bash - run: cp ${{ matrix.binary }} ${{ matrix.artifact_name }} - - - name: Generate SHA256 checksum + - name: Verify immutable release input shell: bash + env: + RELEASE_SETTINGS_READ_TOKEN: ${{ secrets.RELEASE_SETTINGS_READ_TOKEN }} run: | - if command -v sha256sum &>/dev/null; then - sha256sum "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256" - else - shasum -a 256 "${{ matrix.artifact_name }}" > "${{ matrix.artifact_name }}.sha256" + set -euo pipefail + if [[ "$GITHUB_REF" != "refs/tags/$RELEASE_TAG" ]]; then + echo "::error::Dispatch this workflow with --ref on the new version tag." + exit 1 fi - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact_name }} - path: | - ${{ matrix.artifact_name }} - ${{ matrix.artifact_name }}.sha256 - - - name: Attach binary to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: | - ${{ matrix.artifact_name }} - ${{ matrix.artifact_name }}.sha256 - - # ── 2b. Linux packages (.deb, .rpm) ────────────────────────────────────── - - package-deb-x86_64: - name: Package .deb (x86_64) - runs-on: ubuntu-latest - needs: [generate-changelog, lint-release] - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.94" - - - name: Install cargo-deb - uses: taiki-e/install-action@v2 - with: - tool: cargo-deb - - - name: Build release binary - run: cargo build --release --package renderflow-cli - - - name: Build .deb package - run: cargo deb --no-build - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: renderflow-deb-x86_64 - path: target/debian/*.deb - - - name: Attach .deb to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: target/debian/*.deb - - package-deb-aarch64: - name: Package .deb (aarch64) - runs-on: ubuntu-latest - needs: [generate-changelog, lint-release] - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.94" - targets: aarch64-unknown-linux-gnu - - - name: Install cross-compilation toolchain + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]; then + echo "::error::Expected a new SemVer release-candidate tag." + exit 1 + fi + if [[ ! "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::expected_commit must be the full reviewed commit SHA." + exit 1 + fi + git fetch origin main --tags + if [[ "$(git cat-file -t "refs/tags/$RELEASE_TAG")" != "tag" ]] || + [[ "$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" != "$EXPECTED_COMMIT" ]] || + [[ "$(git rev-parse HEAD)" != "$EXPECTED_COMMIT" ]] || + [[ "$(git rev-parse origin/main)" != "$EXPECTED_COMMIT" ]] || + [[ "$GITHUB_SHA" != "$EXPECTED_COMMIT" ]]; then + echo "::error::The annotated tag, checkout, event, and current main must resolve to the same reviewed commit." + exit 1 + fi + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::This tag already has a release; never overwrite or reuse it." + exit 1 + fi + if [[ -z "$RELEASE_SETTINGS_READ_TOKEN" ]]; then + echo "::error::Set RELEASE_SETTINGS_READ_TOKEN with repository Administration read permission." + exit 1 + fi + if [[ "$(GH_TOKEN="$RELEASE_SETTINGS_READ_TOKEN" gh api --header "X-GitHub-Api-Version: 2026-03-10" "repos/$GITHUB_REPOSITORY/immutable-releases" --jq '.enabled')" != "true" ]]; then + echo "::error::Enable GitHub release immutability before publishing." + exit 1 + fi + python3 - <<'PY' + import os + import tomllib + + with open("Cargo.toml", "rb") as source: + version = tomllib.load(source)["workspace"]["package"]["version"] + with open("Cargo.lock", "rb") as source: + packages = tomllib.load(source)["package"] + expected = {"renderflow", "renderflow-cli", "renderflow-plugin-sdk"} + local = {p["name"]: p["version"] for p in packages if p["name"] in expected} + assert os.environ["RELEASE_TAG"] == f"v{version}" + assert local == dict.fromkeys(expected, version), local + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output: + output.write(f"RELEASE_VERSION={version}\n") + PY + git diff --exit-code + + - name: Require exact-commit CI, docs, conformance, and scheduled evidence + shell: bash run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - - - name: Install cargo-deb - uses: taiki-e/install-action@v2 - with: - tool: cargo-deb - - - name: Build release binary for aarch64 - run: cargo build --release --package renderflow-cli --target aarch64-unknown-linux-gnu - env: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - - - name: Build .deb for aarch64 - run: cargo deb --no-build --target aarch64-unknown-linux-gnu - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: renderflow-deb-aarch64 - path: target/aarch64-unknown-linux-gnu/debian/*.deb - - - name: Attach .deb to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: target/aarch64-unknown-linux-gnu/debian/*.deb - - package-rpm-x86_64: - name: Package .rpm (x86_64) - runs-on: ubuntu-latest - needs: [generate-changelog, lint-release] - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.94" - - - name: Install cargo-generate-rpm - uses: taiki-e/install-action@v2 - with: - tool: cargo-generate-rpm - - - name: Build release binary - run: cargo build --release --package renderflow-cli - - - name: Build .rpm package - run: cargo generate-rpm - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: renderflow-rpm-x86_64 - path: target/generate-rpm/*.rpm - - - name: Attach .rpm to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: target/generate-rpm/*.rpm - - package-rpm-aarch64: - name: Package .rpm (aarch64) - runs-on: ubuntu-latest - needs: [generate-changelog, lint-release] - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain + set -euo pipefail + require_green_run() { + local workflow="$1" event="$2" + gh api "repos/$GITHUB_REPOSITORY/actions/workflows/$workflow/runs?head_sha=$EXPECTED_COMMIT&per_page=100" | + jq --exit-status --arg sha "$EXPECTED_COMMIT" --arg event "$event" \ + '[.workflow_runs[] | select(.head_sha == $sha and .event == $event)] | length > 0 and .[0].conclusion == "success"' >/dev/null || { + echo "::error::The latest $workflow $event run for $EXPECTED_COMMIT is not green." + exit 1 + } + } + require_green_run ci.yml push + require_green_run docs.yml push + require_green_run conformance.yml workflow_dispatch + scheduled_json="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/conformance.yml/runs?event=schedule&per_page=1")" + SCHEDULED_JSON="$scheduled_json" python3 - <<'PY' + import datetime as dt + import json + import os + + runs = json.loads(os.environ["SCHEDULED_JSON"])["workflow_runs"] + if len(runs) != 1: + raise SystemExit("::error::No scheduled maximal conformance run was found") + run = runs[0] + completed = dt.datetime.fromisoformat(run["updated_at"].replace("Z", "+00:00")) + age_days = (dt.datetime.now(dt.timezone.utc) - completed).total_seconds() / 86400 + print(f"Latest scheduled maximal conformance: run {run['id']}, " + f"head {run['head_sha']}, completed {run['updated_at']}, " + f"age {age_days:.2f} days, conclusion {run['conclusion']}") + if run["conclusion"] != "success" or not 0 <= age_days <= 8: + raise SystemExit("::error::Latest scheduled maximal conformance is red or older than eight days") + PY + + - name: Install Rust MSRV toolchain uses: dtolnay/rust-toolchain@stable with: toolchain: "1.94" - targets: aarch64-unknown-linux-gnu - - - name: Install cross-compilation toolchain - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - - - name: Install cargo-generate-rpm - uses: taiki-e/install-action@v2 - with: - tool: cargo-generate-rpm - - - name: Build release binary for aarch64 - run: cargo build --release --package renderflow-cli --target aarch64-unknown-linux-gnu - env: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - - - name: Build .rpm for aarch64 - run: cargo generate-rpm --target aarch64-unknown-linux-gnu - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: renderflow-rpm-aarch64 - path: target/aarch64-unknown-linux-gnu/generate-rpm/*.rpm - - - name: Attach .rpm to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: target/aarch64-unknown-linux-gnu/generate-rpm/*.rpm - - # ── 2c. Snap ────────────────────────────────────────────────────────────── - - snap-package: - name: Build Snap package - runs-on: ubuntu-latest - needs: [generate-changelog, lint-release] - permissions: - contents: write + components: clippy, rustfmt - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Fetch tags - run: git fetch --tags - - - name: Inject version into snapcraft.yaml + - name: Verify release manifest and refusal fixtures + shell: bash run: | - VERSION="${{ github.ref_name }}" - VERSION="${VERSION#v}" - sed -i "s/^version: .*/version: '${VERSION}'/" snap/snapcraft.yaml - - - name: Build Snap - uses: snapcore/action-build@v1 - id: snap-build + set -euo pipefail + python3 -m pip install --disable-pip-version-check "jsonschema>=4,<5" + python3 -m unittest discover --start-directory scripts/release \ + --pattern "test_*.py" --verbose - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: renderflow-snap - path: ${{ steps.snap-build.outputs.snap }} - - - name: Attach Snap to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: ${{ steps.snap-build.outputs.snap }} - - # ── 2d. Chocolatey ──────────────────────────────────────────────────────── - - package-chocolatey: - name: Package Chocolatey - runs-on: windows-latest - needs: build-binaries - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download Windows MSVC binary artifact - uses: actions/download-artifact@v4 - with: - name: renderflow-x86_64-pc-windows-msvc.exe - path: pkg/chocolatey/tools - - - name: Capture checksum and update package files - shell: pwsh + - name: Verify source and native conformance + shell: bash run: | - $version = "${{ github.ref_name }}".TrimStart('v') - # Read sha256 before the binary is renamed - $sha256 = (Get-Content "pkg\chocolatey\tools\renderflow-x86_64-pc-windows-msvc.exe.sha256" -Raw).Trim().Split(' ')[0] - $url = "https://github.com/egohygiene/renderflow/releases/download/${{ github.ref_name }}/renderflow-x86_64-pc-windows-msvc.exe" - - # Rename binary so Chocolatey shims it correctly - Rename-Item -Path "pkg\chocolatey\tools\renderflow-x86_64-pc-windows-msvc.exe" ` - -NewName "renderflow.exe" + set -euo pipefail + cargo fmt --all -- --check + cargo clippy --workspace --locked -- --deny warnings + cargo test --workspace --locked + RENDERFLOW_CONFORMANCE_TIER=fast cargo test --package renderflow --test golden_conformance --locked - # nuspec version - (Get-Content "pkg\chocolatey\renderflow.nuspec") ` - -replace '[^<]*', "$version" | - Set-Content "pkg\chocolatey\renderflow.nuspec" - - # install script URL + checksum - # Use double-quoted replacement for checksum so the capture group from - # `(-Checksum64\s+)` (`$1`, escaped as `` `$1 `` in the string) and - # `$sha256` are both interpolated. - (Get-Content "pkg\chocolatey\tools\chocolateyinstall.ps1") ` - -replace 'https://github.com/egohygiene/renderflow/releases/download/v[^/]+/renderflow-x86_64-pc-windows-msvc.exe', $url ` - -replace "(-Checksum64\s+)'[^']*'", "`$1'$sha256'" ` - -replace '(-Checksum64\s+)"[^"]*"', "`$1'$sha256'" | - Set-Content "pkg\chocolatey\tools\chocolateyinstall.ps1" - - - name: Pack Chocolatey package - shell: pwsh - run: choco pack pkg\chocolatey\renderflow.nuspec --output-directory pkg\chocolatey\out - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: renderflow-chocolatey - path: pkg/chocolatey/out/*.nupkg - - - name: Attach Chocolatey package to GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: pkg/chocolatey/out/*.nupkg - - # ── 3. Manifest updates ─────────────────────────────────────────────────── - # Sequential to avoid concurrent pushes to the default branch. - - update-homebrew-formula: - name: Update Homebrew formula - runs-on: ubuntu-latest - needs: [generate-changelog, lint-release] - continue-on-error: true - permissions: - contents: write - - steps: - - name: Checkout repository (main branch) - uses: actions/checkout@v4 - with: - ref: ${{ github.event.repository.default_branch }} - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - - - name: Compute source tarball SHA256 - id: formula + - name: Verify documentation build + shell: bash run: | - VERSION="${{ github.ref_name }}" - URL="https://github.com/egohygiene/renderflow/archive/refs/tags/${VERSION}.tar.gz" - SHA256=$(curl -fsSL "$URL" | sha256sum | awk '{print $1}') - echo "url=${URL}" >> "$GITHUB_OUTPUT" - echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT" + set -euo pipefail + python3 -m pip install --disable-pip-version-check mkdocs-material mike pyyaml + mkdocs build --strict - - name: Update formula url and sha256 + - name: Install exact optional PDF provider for downloaded binary smoke + shell: bash run: | - sed -i 's|url "https://github.com/egohygiene/renderflow/archive/refs/tags/.*"|url "${{ steps.formula.outputs.url }}"|' Formula/renderflow.rb - sed -i 's|sha256 ".*"|sha256 "${{ steps.formula.outputs.sha256 }}"|' Formula/renderflow.rb - - - name: Commit and push updated formula + set -euo pipefail + python3 -m venv "$RUNNER_TEMP/renderflow-img2pdf" + "$RUNNER_TEMP/renderflow-img2pdf/bin/python" -m pip install \ + --disable-pip-version-check "img2pdf==0.6.3" + [[ "$("$RUNNER_TEMP/renderflow-img2pdf/bin/img2pdf" --version)" == "img2pdf 0.6.3" ]] + echo "$RUNNER_TEMP/renderflow-img2pdf/bin" >> "$GITHUB_PATH" + + - name: Build the supported binary and preliminary release assets + shell: bash run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/renderflow.rb - if git diff --staged --quiet; then - echo "Formula already up to date, no commit needed." - else - git commit -m "chore: update Homebrew formula to ${{ github.ref_name }}" - BRANCH="${{ github.event.repository.default_branch }}" - for attempt in 1 2 3 4 5; do - git fetch origin "${BRANCH}" - if git rebase "origin/${BRANCH}"; then - if git push origin "HEAD:${BRANCH}"; then - echo "Pushed Homebrew formula update on attempt ${attempt}." - exit 0 - fi - else - git rebase --abort || true - fi - echo "Push attempt ${attempt} failed; retrying..." - sleep $((2 ** attempt < 10 ? 2 ** attempt : 10)) - done - echo "::warning::Unable to push Homebrew update after retries; continuing release." + set -euo pipefail + cargo build --release --locked --package renderflow-cli --target "$RELEASE_TARGET" + mkdir -p "$RELEASE_ASSETS" + cp "target/$RELEASE_TARGET/release/renderflow" "$RELEASE_ASSETS/renderflow-$RELEASE_TARGET" + [[ "$(getconf GNU_LIBC_VERSION)" == "glibc 2.39" ]] || { + echo "::error::The build runner's glibc differs from the declared Ubuntu 24.04 baseline." + exit 1 + } + required_glibc="$(readelf --version-info "$RELEASE_ASSETS/renderflow-$RELEASE_TARGET" | + grep --only-matching --extended-regexp 'GLIBC_[0-9]+\.[0-9]+' | + sed 's/GLIBC_//' | sort --version-sort --unique | tail --lines=1)" + if [[ -z "$required_glibc" ]] || + [[ "$(printf '%s\n' "$required_glibc" '2.39' | sort --version-sort | tail --lines=1)" != "2.39" ]]; then + echo "::error::Binary references GLIBC $required_glibc above the reviewed 2.39 baseline." + exit 1 fi + printf 'Built on Ubuntu 24.04 / glibc 2.39; binary max GLIBC requirement: %s\n' "$required_glibc" + python3 scripts/release/release_assets.py prepare \ + --artifact-dir "$RELEASE_ASSETS" --version "$RELEASE_VERSION" \ + --tag "$RELEASE_TAG" --commit "$EXPECTED_COMMIT" --target "$RELEASE_TARGET" - update-scoop-manifest: - name: Update Scoop manifest - runs-on: ubuntu-latest - needs: [build-binaries, update-homebrew-formula] - continue-on-error: true - permissions: - contents: write + - name: Freeze binary attestation subject + id: binary_subject + shell: bash + run: | + set -euo pipefail + sha256="$(sha256sum "$RELEASE_ASSETS/renderflow-$RELEASE_TARGET" | cut --delimiter=' ' --fields=1)" + [[ "$sha256" =~ ^[0-9a-f]{64}$ ]] + printf 'digest=sha256:%s\n' "$sha256" >> "$GITHUB_OUTPUT" - steps: - - name: Checkout repository (main branch) - uses: actions/checkout@v4 + - name: Sign GitHub build provenance for the binary + id: provenance + uses: actions/attest@v4 with: - ref: ${{ github.event.repository.default_branch }} - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 + subject-name: renderflow-x86_64-unknown-linux-gnu + subject-digest: ${{ steps.binary_subject.outputs.digest }} - - name: Download Windows MSVC binary artifact - uses: actions/download-artifact@v4 + - name: Sign GitHub SBOM attestation for the binary + id: sbom + uses: actions/attest@v4 with: - name: renderflow-x86_64-pc-windows-msvc.exe - path: /tmp/windows-msvc - - - name: Update Scoop manifest - run: | - VERSION="${{ github.ref_name }}" - VERSION="${VERSION#v}" - SHA256=$(awk '{print $1}' /tmp/windows-msvc/renderflow-x86_64-pc-windows-msvc.exe.sha256) - URL="https://github.com/egohygiene/renderflow/releases/download/${{ github.ref_name }}/renderflow-x86_64-pc-windows-msvc.exe" - python3 - <> "$GITHUB_OUTPUT" - echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT" + set -euo pipefail + cat > "$RUNNER_TEMP/release-notes.md" </dev/null + echo "RELEASE_DOWNLOAD_DIR=$download_dir" >> "$GITHUB_ENV" + + - name: Smoke downloaded assets on a clean offline Ubuntu 24.04 host + shell: bash run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add pkg/aur/renderflow/PKGBUILD - if git diff --staged --quiet; then - echo "PKGBUILD already up to date, no commit needed." - else - git commit -m "chore: update AUR PKGBUILD to ${{ github.ref_name }}" - BRANCH="${{ github.event.repository.default_branch }}" - for attempt in 1 2 3 4 5; do - git fetch origin "${BRANCH}" - if git rebase "origin/${BRANCH}"; then - if git push origin "HEAD:${BRANCH}"; then - echo "Pushed AUR update on attempt ${attempt}." - exit 0 - fi - else - git rebase --abort || true - fi - echo "Push attempt ${attempt} failed; retrying..." - sleep $((2 ** attempt < 10 ? 2 ** attempt : 10)) - done - echo "::warning::Unable to push AUR update after retries; continuing release." - fi - - # ── 4. Verify release ───────────────────────────────────────────────────── - - verify-release: - name: Verify release assets - runs-on: ubuntu-latest - # Manifest jobs are intentionally excluded so release artifact verification - # remains authoritative even when best-effort package manager sync jobs fail. - needs: - - build-binaries - - package-deb-x86_64 - - package-deb-aarch64 - - package-rpm-x86_64 - - package-rpm-aarch64 - - snap-package - - package-chocolatey - permissions: - contents: read - - steps: - - name: Check GitHub Release assets + set -euo pipefail + command -v docker >/dev/null || { + echo "::error::Docker is required for no-checkout release verification." + exit 1 + } + docker info >/dev/null + context_dir="$(mktemp -d)" + cat > "$context_dir/Dockerfile" <<'EOF' + FROM ubuntu:24.04 + RUN apt-get update && apt-get install --yes --no-install-recommends \ + ca-certificates python3 python3-venv python3-pip \ + && python3 -m venv /opt/renderflow-provider \ + && /opt/renderflow-provider/bin/python -m pip install \ + --disable-pip-version-check "img2pdf==0.6.3" \ + && rm -rf /var/lib/apt/lists/* + ENV PATH="/opt/renderflow-provider/bin:${PATH}" + EOF + # The Docker build context contains only this Dockerfile: no source + # tree, local binary, credentials, fixtures, or generated artifacts. + image="renderflow-clean-host:$GITHUB_RUN_ID" + docker build --pull --tag "$image" "$context_dir" + docker image inspect "$image" --format 'Clean-host image ID: {{.Id}}' + verifier="$RUNNER_TEMP/release-verifier.py" + cp scripts/release/release_assets.py "$verifier" + # The image was assembled with network access; the actual release + # execution has no network and only two read-only mounts. + docker run --rm --network none --read-only \ + --security-opt no-new-privileges --cap-drop ALL \ + --tmpfs "/tmp:rw,nosuid,nodev,size=256m" \ + --mount "type=bind,source=$RELEASE_DOWNLOAD_DIR,target=/assets,readonly" \ + --mount "type=bind,source=$verifier,target=/verify.py,readonly" \ + --env "RELEASE_VERSION=$RELEASE_VERSION" \ + --env "RELEASE_TAG=$RELEASE_TAG" \ + --env "EXPECTED_COMMIT=$EXPECTED_COMMIT" \ + --env "RELEASE_TARGET=$RELEASE_TARGET" \ + "$image" bash -euo pipefail -c ' + . /etc/os-release + [[ "$ID" == "ubuntu" && "$VERSION_ID" == "24.04" ]] + [[ "$(getconf GNU_LIBC_VERSION)" == "glibc 2.39" ]] + [[ "$(img2pdf --version)" == "img2pdf 0.6.3" ]] + printf "Clean host: Ubuntu %s, %s, %s\n" \ + "$VERSION_ID" "$(getconf GNU_LIBC_VERSION)" "$(img2pdf --version)" + python3 /verify.py verify \ + --artifact-dir /assets --version "$RELEASE_VERSION" \ + --tag "$RELEASE_TAG" --commit "$EXPECTED_COMMIT" \ + --target "$RELEASE_TARGET" --smoke + ' + + - name: Publish verified immutable prerelease + shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_SETTINGS_READ_TOKEN: ${{ secrets.RELEASE_SETTINGS_READ_TOKEN }} run: | - TAG="${{ github.ref_name }}" - REPO="${{ github.repository }}" - - echo "Verifying release assets for ${TAG}..." - - EXPECTED=( - "renderflow-x86_64-unknown-linux-musl" - "renderflow-x86_64-unknown-linux-musl.sha256" - "renderflow-x86_64-unknown-linux-gnu" - "renderflow-x86_64-unknown-linux-gnu.sha256" - "renderflow-aarch64-unknown-linux-musl" - "renderflow-aarch64-unknown-linux-musl.sha256" - "renderflow-aarch64-unknown-linux-gnu" - "renderflow-aarch64-unknown-linux-gnu.sha256" - "renderflow-armv7-unknown-linux-musleabihf" - "renderflow-armv7-unknown-linux-musleabihf.sha256" - "renderflow-i686-unknown-linux-musl" - "renderflow-i686-unknown-linux-musl.sha256" - "renderflow-x86_64-pc-windows-gnu.exe" - "renderflow-x86_64-pc-windows-gnu.exe.sha256" - "renderflow-x86_64-pc-windows-msvc.exe" - "renderflow-x86_64-pc-windows-msvc.exe.sha256" - "renderflow-x86_64-apple-darwin" - "renderflow-x86_64-apple-darwin.sha256" - "renderflow-aarch64-apple-darwin" - "renderflow-aarch64-apple-darwin.sha256" - ) - EXPECTED_PATTERNS=( - '^renderflow_.*_amd64\.deb$' - '^renderflow_.*_arm64\.deb$' - '^renderflow-.*\.x86_64\.rpm$' - '^renderflow-.*\.aarch64\.rpm$' - '^renderflow_.*\.snap$' - '^renderflow\..*\.nupkg$' - ) - - # Fetch the list of uploaded assets from the GitHub Release. - ASSET_LINES="$(gh release view "${TAG}" --repo "${REPO}" --json assets --jq '.assets[].name')" - - MISSING=() - for ASSET in "${EXPECTED[@]}"; do - if ! printf '%s\n' "${ASSET_LINES}" | grep -Fxq "${ASSET}"; then - MISSING+=("${ASSET}") - fi - done - - for PATTERN in "${EXPECTED_PATTERNS[@]}"; do - if ! printf '%s\n' "${ASSET_LINES}" | grep -Eq "${PATTERN}"; then - MISSING+=("pattern:${PATTERN}") - fi - done - - SOURCE_TARBALL_URL="https://github.com/egohygiene/renderflow/archive/refs/tags/${TAG}.tar.gz" - tarball_ok=false - for attempt in 1 2 3; do - if curl -fsSI "${SOURCE_TARBALL_URL}" >/dev/null; then - tarball_ok=true - break - fi - sleep "${attempt}" - done - if [ "${tarball_ok}" != "true" ]; then - MISSING+=("source-tarball:${SOURCE_TARBALL_URL}") - fi - - if [ ${#MISSING[@]} -gt 0 ]; then - echo "ERROR: The following expected release assets are missing:" - for M in "${MISSING[@]}"; do - echo " - ${M}" - done + set -euo pipefail + if [[ -z "$RELEASE_SETTINGS_READ_TOKEN" ]] || + [[ "$(GH_TOKEN="$RELEASE_SETTINGS_READ_TOKEN" gh api \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "repos/$GITHUB_REPOSITORY/immutable-releases" --jq '.enabled')" != "true" ]]; then + echo "::error::Repository release immutability is no longer enabled. Draft remains unpublished." exit 1 fi - - echo "All expected release assets are present." - echo "Release ${TAG} verified successfully." + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --draft=false --prerelease --latest=false --verify-tag + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft,isImmutable,isPrerelease,tagName,url | + jq --exit-status --arg tag "$RELEASE_TAG" \ + 'select(.isDraft == false and .isImmutable == true and .isPrerelease == true and .tagName == $tag) | .url' + gh release verify "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" + gh release verify-asset "$RELEASE_TAG" \ + "$RELEASE_DOWNLOAD_DIR/renderflow-$RELEASE_TARGET" \ + --repo "$GITHUB_REPOSITORY" diff --git a/.gitignore b/.gitignore index abd0e29..7ed2298 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ site/ node_modules/ apps/web/coverage/ apps/web/.vitest/ + +# Python release helper bytecode +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b581398..88d981c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Proposed integration candidate + +- `v0.3.0-rc.1` is the proposed first verified Flow integration candidate; + this entry is not evidence of an existing GitHub release. It packages the + reviewed exact ordered-collection, print-interior PDF, fixed-layout EPUB, + and independent EPUB inspection routes (#415–#418), subject to the release + gate in #419. Initial binary support is scoped to Ubuntu 24.04 x86_64 GNU + (glibc 2.39 or newer) after + downloaded-asset verification. Other binary and package-manager channels + remain unverified or unpublished for this candidate. +- Historical `v0.2.1` is an unsigned tag without a GitHub release. It is not + reused as the integration candidate. + ### Features - Add a typed, bounded HandBrakeCLI adapter for whole-file video derivatives, @@ -52,4 +65,3 @@ All notable changes to this project will be documented in this file. ### Improvements - Update select_strategy to accept references instead of owned values - diff --git a/Cargo.lock b/Cargo.lock index 73bd711..7f14b7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1670,7 +1670,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "renderflow" -version = "0.2.1" +version = "0.3.0-rc.1" dependencies = [ "anyhow", "clap", @@ -1702,7 +1702,7 @@ dependencies = [ [[package]] name = "renderflow-cli" -version = "0.2.1" +version = "0.3.0-rc.1" dependencies = [ "anyhow", "renderflow", @@ -1714,7 +1714,7 @@ dependencies = [ [[package]] name = "renderflow-plugin-sdk" -version = "0.2.1" +version = "0.3.0-rc.1" dependencies = [ "anyhow", "renderflow", diff --git a/Cargo.toml b/Cargo.toml index c2a3aa1..241a3f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.1" +version = "0.3.0-rc.1" edition = "2021" rust-version = "1.94" license = "MIT" diff --git a/Formula/renderflow.rb b/Formula/renderflow.rb index 27606c1..67e1fd9 100644 --- a/Formula/renderflow.rb +++ b/Formula/renderflow.rb @@ -2,12 +2,11 @@ # frozen_string_literal: true class Renderflow < Formula - desc "Spec-driven document rendering engine" + desc "Unpublished source-development formula for Renderflow" homepage "https://github.com/egohygiene/renderflow" - # url and sha256 are updated automatically by CI on each tagged release. - # Until the first release is published, install via: brew install --HEAD renderflow - url "https://github.com/egohygiene/renderflow/archive/refs/tags/v0.2.1.tar.gz" - sha256 "0000000000000000000000000000000000000000000000000000000000000000" + # HEAD-only source-development template. No candidate Homebrew channel has + # been published or independently verified. Do not add a stable URL until + # its exact source archive and checksum have release evidence. license "MIT" head "https://github.com/egohygiene/renderflow.git", branch: "main" diff --git a/README.md b/README.md index 3d843d8..ddac2ae 100644 --- a/README.md +++ b/README.md @@ -103,98 +103,32 @@ dist/ ## Installation -### Via Homebrew (macOS and Linux) +The first integration candidate is proposed as `v0.3.0-rc.1`. A Cargo version +or checked-in package recipe does **not** mean an installable release exists. +Check the [GitHub releases](https://github.com/egohygiene/renderflow/releases) +for the immutable tag, actual assets, checksums, and release manifest before +installing. Historical `v0.2.1` was an unsigned tag with no GitHub release and +must not be treated as the verified candidate. + +The first verified binary environment is intended to be Ubuntu 24.04 x86_64 +(GNU libc 2.39 or newer). The asset target name is +`renderflow-x86_64-unknown-linux-gnu`; other distributions and libc baselines +have not been clean-install verified. +Other platforms and Homebrew, Scoop, Chocolatey, Snap, AUR, `.deb`, `.rpm`, and +crates.io distribution are **unverified or unpublished** for this candidate; +checked-in recipes are preparation, not evidence of availability. Consult the +[installation and release-status guide](docs/getting-started/installation.md) +for a digest-pinned install procedure once the asset is published. + +To build the reviewed source locally with Rust 1.94 or newer: ```bash -brew trust egohygiene/renderflow -brew tap egohygiene/renderflow https://github.com/egohygiene/renderflow -brew install renderflow +cargo install --locked --path "crates/renderflow-cli" ``` -> **Note:** The `brew trust` step is required before tapping because this is a third-party tap. Without it, Homebrew will refuse to load the formula with an "untrusted tap" error. - -Pandoc is installed automatically as a dependency. - -### Via Scoop (Windows) - -```powershell -scoop bucket add egohygiene https://github.com/egohygiene/renderflow -scoop install renderflow -``` - -### Via Chocolatey (Windows) - -```powershell -choco install renderflow -``` - -### Via Snap (Linux) - -```bash -snap install renderflow --classic -``` - -### Portable install script (macOS/Linux) - -```bash -curl -fsSL https://raw.githubusercontent.com/egohygiene/renderflow/main/scripts/install.sh | sh -``` - -You can pin a release version with `RENDERFLOW_VERSION` and override install location with `RENDERFLOW_INSTALL_DIR`. - -### Via AUR (Arch Linux) - -Stable release: - -```bash -yay -S renderflow -``` - -Latest git build: - -```bash -yay -S renderflow-git -``` - -### Via Debian / Ubuntu (.deb) - -Download the `.deb` for your architecture from the [Releases page](https://github.com/egohygiene/renderflow/releases/latest) and install: - -```bash -sudo dpkg -i renderflow_*.deb -``` - -### Via RHEL / Fedora / openSUSE (.rpm) - -Download the `.rpm` for your architecture from the [Releases page](https://github.com/egohygiene/renderflow/releases/latest) and install: - -```bash -sudo rpm -i renderflow-*.rpm -``` - -### Download pre-built binary (all platforms) - -Pre-built binaries are available for Linux (x86_64, aarch64, ARMv7, i686), macOS (Intel, Apple Silicon), and Windows (x86_64) on the [Releases page](https://github.com/egohygiene/renderflow/releases/latest). - -| Platform | Binary | -|---|---| -| Linux x86_64 (musl) | `renderflow-x86_64-unknown-linux-musl` | -| Linux x86_64 (glibc) | `renderflow-x86_64-unknown-linux-gnu` | -| Linux aarch64 (musl) | `renderflow-aarch64-unknown-linux-musl` | -| Linux aarch64 (glibc) | `renderflow-aarch64-unknown-linux-gnu` | -| Linux ARMv7 | `renderflow-armv7-unknown-linux-musleabihf` | -| Linux i686 | `renderflow-i686-unknown-linux-musl` | -| macOS Intel | `renderflow-x86_64-apple-darwin` | -| macOS Apple Silicon | `renderflow-aarch64-apple-darwin` | -| Windows x86_64 | `renderflow-x86_64-pc-windows-msvc.exe` | - -### Build from source - -Requires [Rust](https://rustup.rs) and [Pandoc](https://pandoc.org/installing.html). - -```bash -cargo install --path . -``` +External providers such as Pandoc and the exact `img2pdf` 0.6.3 needed by +the print-interior route are separate host tools. The fixed-layout EPUB +packager is native; EPUBCheck v5 is optional, separate evidence. ### Verify installation @@ -231,11 +165,11 @@ renderflow build --debug ### Using Renderflow as a Rust library -Renderflow also ships as a reusable Rust crate for embedding in your own tools. - -```bash -cargo add renderflow -``` +The `renderflow` workspace crate provides the reusable SDK. The first +integration candidate does not claim a verified crates.io publication; run +the examples from a reviewed checkout until that distribution route is +independently verified. Flow's production adapter will consume the released +binary and manifest by version and digest, without importing this source. Runnable SDK examples are included in this repository: @@ -717,27 +651,35 @@ The CLI does not reimplement planning or execution logic; it delegates to the co ### Compatibility policy -- The `renderflow` public API follows semantic versioning. -- New APIs may be added in minor releases; breaking changes are reserved for major releases. -- Deprecated APIs remain available for at least one minor release before removal. +- The first integration candidate is a `0.3.0-rc.1` prerelease. Flow must pin + its binary digest and declared contracts; a matching crate version alone is + insufficient compatibility evidence. +- The plugin-facing contract is `renderflow.plugin/v2alpha1`. It is an alpha + API, not a stable third-party ABI guarantee. Changes require explicit + contract/version and downstream migration review. +- Stable API and deprecation windows will be set after integration evidence; + do not infer v1 guarantees from this candidate. --- ## Release Process -Releases are fully automated. Run the **Bump Version** workflow from GitHub Actions: - -1. Navigate to **Actions → Bump Version → Run workflow**. -2. Select the bump level (`patch` / `minor` / `major`) or enter an explicit version. -3. The workflow bumps `Cargo.toml`, updates the workspace package version in `Cargo.lock`, updates package manifest versions, creates an annotated tag, pushes to `main`, and dispatches the release pipeline. - -The **Release** workflow then: -- Generates `CHANGELOG.md` and release notes with `git-cliff`. -- Cross-compiles release binaries for all 10 supported targets. -- Builds `.deb`, `.rpm`, `.snap`, and `.nupkg` packages. -- Uploads all artifacts to the GitHub Release. -- Updates Homebrew, Scoop, and AUR package manifests with retry/rebase safeguards. -- Verifies required binaries, checksums, package artifacts, and source tarball availability. +The `v0.3.0-rc.1` integration candidate begins with a reviewed version PR and +maintainer merge. After local and required CI gates pass, the maintainer creates +an annotated tag at the **exact** reviewed commit and explicitly dispatches the +tag-ref release workflow. Staged artifacts must pass downloaded-byte and +clean-install verification before the GitHub prerelease is published. No +version-bump workflow may mutate `main` or create a tag on its own. +The immutable-releases setting must be enabled and a read-only +`RELEASE_SETTINGS_READ_TOKEN` secret must allow the workflow to verify it; +missing evidence blocks publication. + +The gate includes checksums, a machine-readable Flow lock manifest, +SBOM/notices, provenance, and a recorded signing decision. Historical `v0.2.1` +remains untouched. Publication and package-channel availability are reported +only after verification; see the [release-candidate guide](docs/release-candidate.md) +for the matrix, rollback, and compromised-release response. The old broad +package-manager templates do not establish supported distribution. --- @@ -746,7 +688,7 @@ The **Release** workflow then: - [ ] Built-in stylesheet themes - [ ] SVG / emoji embedding in PDFs - [ ] More example configs and templates -- [x] Automated release workflow for pre-built binaries +- [ ] First verified, immutable Ubuntu 24.04 x86_64 GNU integration-candidate release - [x] Graph engine with DAG-based transform planner - [x] AI transform integration (Ollama / OpenAI) - [x] Audio and image format conversion via FFmpeg diff --git a/ROADMAP.md b/ROADMAP.md index 7bd8863..ee78fb9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,28 @@ supersedes: [] # Renderflow Roadmap +## 2026-09-28 integration-candidate release review handoff + +[#418](https://github.com/egohygiene/renderflow/issues/418) merged in +[PR #436](https://github.com/egohygiene/renderflow/pull/436) at +`ff6a76f4bad17541d6554e514ccb345f7c3d3e3c`, and #414 was reconciled +and closed. [#419](https://github.com/egohygiene/renderflow/issues/419) owns +the first verified, immutable integration candidate for +[Flow #52](https://github.com/egohygiene/flow/issues/52). + +The proposed version is `v0.3.0-rc.1`; it is not released by a version edit +or a review branch. A maintainer merge, exact green commit, manually created +annotated tag, staged artifact checks, independent clean-install smoke, and +verified prerelease are the gates. Initial binary scope is Ubuntu 24.04 x86_64 +GNU (glibc 2.39 or newer); other distribution baselines are unverified. +Other binaries and package-manager channels are unverified or unpublished for +this candidate. Historical unsigned `v0.2.1` is preserved without retagging. +The [release guide](docs/release-candidate.md) records exact contracts, +provider/platform limits, provenance and signing status, rollback, and +compromised-release response. Flow consumes only the published immutable +version and digest; no Renderflow source import or automatic #52 start is +implied by this branch. + ## 2026-09-28 fixed-layout EPUB validation review handoff [#417](https://github.com/egohygiene/renderflow/issues/417) merged in diff --git a/apps/web/src/content/product.ts b/apps/web/src/content/product.ts index 801c954..5d49a12 100644 --- a/apps/web/src/content/product.ts +++ b/apps/web/src/content/product.ts @@ -88,30 +88,30 @@ export const architectureLinks: readonly ExternalLink[] = [ export const installationMethods: readonly InstallationMethod[] = [ { - identifier: "cargo", - title: "Cargo", - command: "cargo install renderflow", - notes: "Available for Rust-centric workflows and local source builds.", + identifier: "source-checkout", + title: "Reviewed source checkout", + command: "cargo install --locked --path crates/renderflow-cli", + notes: + "Run from the reviewed repository root with Rust 1.94 or newer. This does not verify a downloaded release artifact or crates.io publication.", status: "available", documentationPath: "https://github.com/egohygiene/renderflow/blob/main/docs/getting-started/installation.md", }, { - identifier: "homebrew", - title: "Homebrew", - command: "brew install egohygiene/tap/renderflow", - notes: "Documented first-party macOS and Linux package channel.", - status: "available", + identifier: "linux-release", + title: "Ubuntu 24.04 x86_64 GNU candidate", + notes: + "v0.3.0-rc.1 is proposed for glibc 2.39 or newer on Ubuntu 24.04. Other distro baselines are unverified. Install only after the exact tag, binary, checksum, manifest, and clean-host evidence are published.", + status: "planned", documentationPath: "https://github.com/egohygiene/renderflow/blob/main/docs/getting-started/installation.md", }, { - identifier: "portable-installer", - title: "Portable installer", - command: - "curl -fsSL https://raw.githubusercontent.com/egohygiene/renderflow/main/scripts/install.sh | sh", - notes: "Supports version pinning and install directory overrides.", - status: "available", + identifier: "package-managers", + title: "Package-manager channels", + notes: + "Homebrew, Scoop, Chocolatey, Snap, AUR, Debian/RPM, and crates.io are unpublished or unverified for the first integration candidate.", + status: "planned", documentationPath: "https://github.com/egohygiene/renderflow/blob/main/docs/getting-started/installation.md", }, diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index 153b9df..da6e854 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -168,10 +168,11 @@ export default function HomePage() {

Installation

-

Only documented installation paths

+

Installation and release status

- Commands below are limited to installation methods already documented in this - repository, with planned distribution targets called out explicitly. + The proposed v0.3.0-rc.1 candidate has no verified download yet. The first verified + environment is Ubuntu 24.04 x86_64 GNU; install it only after the immutable release and + digest evidence are published.

diff --git a/docs/generated/repository-roadmap.md b/docs/generated/repository-roadmap.md index 1887de9..ee66bd2 100644 --- a/docs/generated/repository-roadmap.md +++ b/docs/generated/repository-roadmap.md @@ -1,5 +1,12 @@ # Repository Maturity Roadmap +> **Historical audit snapshot.** This 2026-07-21 generated document describes +> a former multi-platform goal, not observed current release support. The +> active [Renderflow roadmap](https://github.com/egohygiene/renderflow/blob/main/ROADMAP.md) and +> [integration-candidate release guide](../release-candidate.md) govern the +> `v0.3.0-rc.1` scope: Ubuntu 24.04 x86_64 GNU (glibc 2.39 or newer) only +> after publication and verification; other distributions are unverified. + > Generated: 2026-07-21 > Based on: v1.0.0 release readiness audit, canonical repository specification > Specification: `.github/specs/repository/repository.spec.md` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 45464fa..512843f 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,174 +1,96 @@ -# Installation +# Installation and distribution status -Renderflow is distributed through multiple package channels and can also be built from source. +`v0.3.0-rc.1` is the proposed first integration candidate. Until its immutable +tag, GitHub prerelease, and verified assets exist, there is no supported +download for this candidate. The historical `v0.2.1` tag was unsigned and +never accompanied by a GitHub release. Do not install it as the verified +integration candidate. -## Requirements +## Supported target and channels -- Rust 1.94+ for source builds -- Pandoc for document rendering -- Tectonic for PDF output -- FFmpeg for audio/image conversion +| Route | Candidate status | Boundary | +| --- | --- | --- | +| GitHub Release, Ubuntu 24.04 x86_64 GNU binary | Planned for first verified candidate | `renderflow-x86_64-unknown-linux-gnu`, glibc 2.39 or newer, exact tag and SHA-256 required; other distribution baselines unverified | +| Rust source checkout | Local development | Rust 1.94+ and the locked workspace dependencies; not a downloaded binary smoke test | +| macOS, Windows, Linux ARM/musl/other binary targets | Unverified | Existing build configuration alone does not establish supported artifacts | +| crates.io, Homebrew, Scoop, Chocolatey, Snap, AUR, Debian/RPM | Unpublished or unverified for this candidate | Checked-in packaging files and release jobs are not proof of working distribution | -!!! note - Package manager installs may already pull some dependencies for you, but the runtime still needs the external tools required by the outputs you choose. +This matrix describes the intended `v0.3.0-rc.1` scope **before publication**. +For the post-publication state, inspect the [actual GitHub release](https://github.com/egohygiene/renderflow/releases) +and its [release evidence](../release-candidate.md). Do not assume that +`/releases/latest` resolves to a prerelease: pin the exact tag and digest. -## Cargo +## Install the candidate after publication -Install from crates.io: +First verify that `v0.3.0-rc.1` appears as an immutable GitHub prerelease with +the Ubuntu 24.04 x86_64 GNU binary, its `.sha256`, and +`renderflow-release-manifest-v1.json` with its `.sha256`. Download the assets +from that exact tag and compare the binary hash to the checksum file and +manifest. A successful comparison proves byte identity with +the published digest, not the safety of an unreviewed upstream binary. -```bash -cargo install renderflow -``` - -To install from a local checkout instead: - -```bash -cargo install --path . -``` - -## Homebrew - -```bash -brew install egohygiene/tap/renderflow -``` - -If Homebrew refuses to use the third-party tap, trust and tap it explicitly: +The first-party installer can download and verify the matching per-asset +SHA-256 before replacing a local executable. Fetch the script from the pinned +tag so later `main` edits do not change this installation procedure: ```bash -brew trust egohygiene/renderflow -brew tap egohygiene/renderflow https://github.com/egohygiene/renderflow -brew install renderflow +curl --fail --show-error --silent --location \ + --output "renderflow-install.sh" \ + "https://raw.githubusercontent.com/egohygiene/renderflow/v0.3.0-rc.1/scripts/install.sh" +RENDERFLOW_VERSION="v0.3.0-rc.1" \ +RENDERFLOW_INSTALL_DIR="$HOME/.local/bin" \ + sh "renderflow-install.sh" +"$HOME/.local/bin/renderflow" --version ``` -## Portable install script (macOS/Linux) - -Use the first-party installer to auto-detect OS/architecture, download the matching release asset, verify SHA256 checksums, and install `renderflow`: +This route is verified only on Ubuntu 24.04 x86_64 with GNU libc 2.39 or newer; +other distribution and libc baselines have not been verified. The installer cannot +establish SBOM, provenance, or signing status on its own; review those +separately on the release. An absent asset, checksum, or manifest is a failed +install gate, not evidence of a supported platform. The script requires an +exact `RENDERFLOW_VERSION`; mutable `latest` is refused. -```bash -curl -fsSL https://raw.githubusercontent.com/egohygiene/renderflow/main/scripts/install.sh | sh -``` +## Build from source for development -Or with `wget`: - -```bash -wget -qO- https://raw.githubusercontent.com/egohygiene/renderflow/main/scripts/install.sh | sh -``` - -Optional environment variables: - -- `RENDERFLOW_VERSION` (default: `latest`) — install a specific release (for example `0.2.1` or `v0.2.1`) -- `RENDERFLOW_INSTALL_DIR` (default: `/usr/local/bin`, fallback: `~/.local/bin`) - -## Scoop (Windows) - -Renderflow ships a Scoop manifest in `pkg/scoop/renderflow.json`. - -```powershell -scoop bucket add egohygiene https://github.com/egohygiene/renderflow -scoop install renderflow -``` - -## AUR (Arch Linux) - -Stable package: - -```bash -yay -S renderflow -``` - -Git package: - -```bash -yay -S renderflow-git -``` - -## Snap - -```bash -snap install renderflow --classic -``` - -## Binary downloads - -Prebuilt binaries are published on the [GitHub Releases page](https://github.com/egohygiene/renderflow/releases/latest). - -Typical assets include: - -- `renderflow-x86_64-unknown-linux-musl` -- `renderflow-x86_64-unknown-linux-gnu` -- `renderflow-aarch64-unknown-linux-gnu` -- `renderflow-aarch64-apple-darwin` -- `renderflow-x86_64-apple-darwin` -- `renderflow-x86_64-pc-windows-msvc.exe` - -Download the binary for your platform, place it on your `PATH`, and make it executable on Unix-like systems: - -```bash -chmod +x renderflow-* -mv renderflow-* /usr/local/bin/renderflow -``` - -## From source - -```bash -git clone https://github.com/egohygiene/renderflow.git -cd renderflow -cargo build --release -cargo install --path . -``` - -## Verify the install +Use an exact reviewed checkout, Rust 1.94 or newer, and its committed lockfile: ```bash +cargo install --locked --path "crates/renderflow-cli" renderflow --version -renderflow version -renderflow env -renderflow doctor renderflow --help +renderflow doctor ``` -## Upgrade - -Use your package manager's native upgrade flow when installed from a package channel. - -Examples: - -```bash -brew upgrade renderflow -scoop update renderflow -snap refresh renderflow -``` - -If you installed with the portable installer, re-run the installer command to fetch the latest release or set `RENDERFLOW_VERSION` for a pinned upgrade. +Building from source does not substitute for an independently verified release +asset. Avoid treating the checked-in Homebrew/Scoop/Chocolatey/AUR templates as +installable release metadata while they retain placeholder checksums or refer +to a tag without a published package. -## Uninstall +## External providers -Package-manager uninstall examples: +The CLI has several exact and optional provider routes. The binary does not +bundle Pandoc, Tectonic, FFmpeg, `img2pdf`, EPUBCheck, HandBrakeCLI, or other +host tools. Install only providers needed for the chosen action and inspect +`renderflow doctor` or `renderflow tools list` on that host. -```bash -brew uninstall renderflow -scoop uninstall renderflow -snap remove renderflow -``` +| Route | Tool boundary | +| --- | --- | +| Standard document rendering | Pandoc; PDF variants may additionally require Tectonic or TeX components | +| Ordered print-interior PDF | Explicit local `img2pdf` 0.6.3, plus bounded PNG/JPEG and page-geometry contract | +| Ordered fixed-layout EPUB | Native packager for the declared PNG/JPEG route; optional EPUBCheck v5 supplies separate external evidence | +| Media conversions | FFmpeg or the selected external adapter, when that route is used | -Portable installer uninstall: +An installed CLI is not proof that every format, provider, reading system, +printer, or retailer is supported. See the [release compatibility contract](../release-candidate.md) +and each route's user guide before running it on personal files. -```bash -rm -f /usr/local/bin/renderflow -# or: -rm -f ~/.local/bin/renderflow -``` +## Upgrade, rollback, and compromised releases -## Additional distribution targets (status) - -| Target | Status | -|---|---| -| Docker / OCI images | Planned | -| Dev Container (`.devcontainer`) | Available | -| GitHub Codespaces | Supported via Dev Container | -| Nix / Nix Flakes | Planned | -| Alpine package | Planned | -| Winget | Planned | -| pkgx | Planned | -| mise | Planned | -| asdf | Planned | +Use exact tags and recorded digests for upgrades. Keep the last verified +binary and manifest outside the installation path; a rollback restores those +same bytes after rechecking their digest, rather than moving an old tag. If a +release is suspected compromised, stop distribution, quarantine its digest in +downstream lockfiles, publish an advisory and revoked status, investigate the +tag and attestation, then issue a newly numbered, reviewed replacement. Never +retag or silently replace a published asset. The +[release-candidate guide](../release-candidate.md) has the response checklist. diff --git a/docs/index.md b/docs/index.md index 0decf23..52be359 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,8 +2,15 @@ Renderflow is a spec-driven rendering engine for turning a single source document into repeatable outputs such as HTML, PDF, DOCX, audio, and images. It combines a YAML configuration file, an in-memory transform pipeline, and a DAG-based planner for graph-driven conversions. +The first Flow integration candidate is proposed as `v0.3.0-rc.1`. Its +[installation status](getting-started/installation.md) and +[release evidence gate](release-candidate.md) distinguish the planned Linux +x86_64 GNU asset from published, independently verified binaries and package +channels. + !!! note - Use the version selector in the site header to switch between the latest published docs and tagged release snapshots. + Documentation snapshots do not establish a released binary. Use the exact + release tag and artifact digest when installing a verified candidate. ## Why Renderflow? @@ -68,6 +75,7 @@ owner of temporal segmentation, segment manifests, and reconstruction. ## Start here - [Installation](getting-started/installation.md) +- [Release candidate and evidence](release-candidate.md) - [Quick Start](getting-started/quickstart.md) - [Configuration](user-guide/configuration.md) - [Supported Formats](user-guide/supported-formats.md) diff --git a/docs/release-candidate.md b/docs/release-candidate.md new file mode 100644 index 0000000..fe2762c --- /dev/null +++ b/docs/release-candidate.md @@ -0,0 +1,125 @@ +# Integration candidate and release evidence + +Renderflow's first Flow integration candidate is proposed as `v0.3.0-rc.1`. +This document describes the contract and publication gate; the presence of a +version in source is not a published release. The maintainer merges the reviewed +version PR, waits for required checks on the exact commit, then explicitly +creates a new annotated tag at that commit and dispatches release work with +the tag ref and full `expected_commit` SHA. Tag pushes alone do not publish. +The release workflow refuses to proceed if the tag, checkout, workflow event, +and current `main` no longer point at that commit. +The historical unsigned `v0.2.1` tag at +`96d1e55231c30449b12f4849d7cdda63853abc68` had no GitHub release. It +must not be moved, reused, or presented as verified release evidence. + +## Scope and compatibility + +The intended first downloaded binary is +`renderflow-x86_64-unknown-linux-gnu` on Ubuntu 24.04 x86_64 (GNU libc 2.39 +or newer). Other distribution and libc baselines are unverified. Its +support begins only when a published prerelease provides the binary, matching +SHA-256, clean-install result, and immutable release manifest. Linux +ARM/musl/i686, macOS, and Windows remain unverified for this candidate. +Container, crates.io, Homebrew, Scoop, Chocolatey, Snap, AUR, `.deb`, and `.rpm` +channels have no candidate publication claim. Checked-in build and packaging +recipes do not constitute package-manager availability. + +| Surface | Candidate contract | Boundary | +| --- | --- | --- | +| CLI | `renderflow` `0.3.0-rc.1` binary and its exact digest | The release asset's `--version`, `--help`, `doctor`, planning, dry-run, fixture run, and failure exits need downloaded-asset evidence on Ubuntu 24.04 x86_64 GNU | +| Source build | Cargo workspace `0.3.0-rc.1`, minimum Rust 1.94 | Source-checkout tests are distinct from release installation | +| Spec | `renderflow/v2` | Explicit ordered collection and exact targets; not arbitrary format composition | +| Execution | `renderflow.run/v1`, `renderflow.artifact-manifest/v1`, `flow.artifact/v1` | Flow consumes released artifacts and manifests, not Renderflow source | +| Provider | `renderflow.provider/v1`; tool registry `renderflow.tool-registry/v1` | The selected tool/version and capability must appear in each plan/run | +| Plugin SDK | `renderflow.plugin/v2alpha1` | Alpha contract; semver of the crate does not imply stable third-party ABI | +| Ordered print PDF | `publication.generate.pdf.interior` | Homogeneous PNG/JPEG; trusted local `img2pdf` 0.6.3; exact geometry and independent PDF inspection; no printer acceptance | +| Fixed EPUB | `ebook.generate.epub.fixed-layout` | Homogeneous PNG/JPEG; native bounded packager; `renderflow.ebook-evidence/v1` inspection; optional EPUBCheck v5 evidence separate from native result | +| Other tools | Route-specific external providers | Pandoc, Tectonic, FFmpeg, HandBrakeCLI, AI runtimes, etc. are neither bundled nor globally guaranteed by binary installation | + +Fixed-layout KEPUB generation, SVG page input, complete accessibility, +retailer approval, physical print proof, publication approval, and arbitrary +real-media execution are outside this candidate. A provider's availability +on one host does not turn an experimental or unselected adapter into a +supported release route. See [ordered collections](user-guide/ordered-collections.md), +[print PDF](user-guide/print-interior-pdf.md), and +[fixed EPUB](user-guide/fixed-layout-epub.md) for limits and refusal behavior. + +## Publication setup and independent verification + +Before dispatch, enable the repository's immutable-releases setting and provide +`RELEASE_SETTINGS_READ_TOKEN` as a repository secret. This fine-grained token +needs **Administration: read** for the repository, solely to read the release +immutability setting; the default `GITHUB_TOKEN` does not have that permission. +The workflow fails closed when the setting cannot be observed as enabled. Do +not place the token in artifacts, manifests, logs, or command examples. + +A release is ready only when its tag, checkout, current `main`, binary, and +release manifest bind the **same** reviewed commit. Release gates are: + +1. Main CI, docs, and one manually dispatched conformance run with both fast + and maximal jobs are green on the exact reviewed commit. The latest + scheduled maximal conformance run must be green **and no older than eight + days** as a separate health gate; its SHA can predate the release commit. + The workflow logs that run's ID, SHA, completion time, and age without + treating it as exact-commit proof. Source and release verification run + again from the exact tag. A failing or stale gate blocks publication rather + than being promoted through an undocumented exception. +2. The tag is annotated and resolves to the reviewed commit. Release work is + explicitly dispatched with `--ref "v0.3.0-rc.1"` and the full + `--field "expected_commit="`; it stages + candidate artifacts without modifying `main` or replacing an existing + tag/asset. +3. The downloaded Linux binary's SHA-256 matches its checksum and the + machine-readable `renderflow-release-manifest-v1.json` and its `.sha256`. + The `renderflow.release-manifest/v1` schema binds version, tag, commit, + target, verified `ubuntu-24.04-x86_64` host and GNU libc 2.39 minimum, + artifact name, digest, exact run/artifact/Flow/tool-registry contracts, + selected providers, and status. +4. `renderflow-sbom.spdx.json` and `THIRD_PARTY_NOTICES.txt` (both with + checksums) identify dependencies and license notices. GitHub/Sigstore + `renderflow-attestation.json` and `renderflow-sbom-attestation.json` + attest to the binary and SBOM. The first candidate has **no separate + maintainer signature on the binary or tag**. Verify the attestations + independently; a checksum is not a signature, and attestation is not a + package-manager publication or platform code signature. +5. A fresh Ubuntu 24.04 Docker image is built from a Dockerfile-only context, + without a repository checkout or local binary. Provider installation during + image construction pins `img2pdf` 0.6.3. Before publication, the **draft's + downloaded assets** and a copied verifier script are mounted read-only; + the actual smoke runs with network disabled and a temporary writable + directory. It checks `--version`, help, doctor, spec validation, canonical + planning, dry-run, deterministic synthetic fixed-layout EPUB and two-page + print-interior PDF execution, independent output inspection, and expected + nonzero stale-input refusal. The EPUB packager is native; optional + EPUBCheck conformance remains separate. +6. The release page lists only assets actually uploaded and verified. + Package-manager channels remain unavailable until separately installed and + tested from their published distribution endpoints. + +Flow should pin the exact version and binary digest from the release manifest; +never infer support solely from a tag name, README table, or mutable `latest` +URL. Staged and downloaded **draft** checks happen before publication and fail +closed, with candidate evidence retained for investigation. The postpublish +`gh release verify` and `gh release verify-asset` checks confirm the resulting +immutable release but cannot undo or block the publication that already +occurred. If either detects a problem, Flow must refuse that version/digest, +halt further distribution, preserve evidence, and follow the incident response +below; do not overwrite the published asset or retarget its tag. + +## Rollback and compromised-release response + +For a faulty but uncompromised candidate, stop selecting its digest in Flow, +retain the incident's tag/commit/artifact evidence, and pin the last previously +verified version by digest. Produce a newly numbered replacement after review; +do not retarget an old tag or overwrite a GitHub asset. If no prior verified +release exists, disable the provider route until a replacement is verified. + +For suspected compromise, stop downloads and integration immediately, mark the +specific version and digest revoked in downstream locks and advisory text, +preserve release/build logs and attestations, investigate credentials and +build inputs, rotate affected secrets, and publish a security advisory or +incident notice describing affected assets and verification steps. Remove or +mark unsafe distribution links without changing historical tag/asset identity. +A replacement requires fresh review, rebuilt artifacts, clean-host evidence, +and a new immutable tag. A checksum alone proves equality to a published hash; +it does not establish trust when the publishing account or build is compromised. diff --git a/mkdocs.yml b/mkdocs.yml index fd3e6ba..8614178 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - Home: index.md - Getting Started: - Installation: getting-started/installation.md + - Release candidate and evidence: release-candidate.md - Quick Start: getting-started/quickstart.md - CLI Overview: getting-started/cli-overview.md - User Guide: diff --git a/pkg/aur/renderflow-git/PKGBUILD b/pkg/aur/renderflow-git/PKGBUILD index 5a43945..d143349 100644 --- a/pkg/aur/renderflow-git/PKGBUILD +++ b/pkg/aur/renderflow-git/PKGBUILD @@ -1,8 +1,9 @@ # Maintainer: Ego Hygiene +# Unpublished source-development template; AUR distribution is unverified. pkgname=renderflow-git pkgver=r1.c97b893 pkgrel=1 -pkgdesc="Spec-driven document rendering engine" +pkgdesc="Unpublished source-development template for Renderflow" arch=('x86_64' 'aarch64') url="https://github.com/egohygiene/renderflow" license=('MIT') diff --git a/pkg/aur/renderflow/PKGBUILD b/pkg/aur/renderflow/PKGBUILD index 91776b1..a278776 100644 --- a/pkg/aur/renderflow/PKGBUILD +++ b/pkg/aur/renderflow/PKGBUILD @@ -1,7 +1,8 @@ # Maintainer: Ego Hygiene -# pkgver and sha256sums are updated automatically by CI on each tagged release. +# UNPUBLISHED TEMPLATE: no verified AUR channel exists for this candidate. +# The placeholder checksum deliberately prevents installation. pkgname=renderflow -pkgver=0.2.1 +pkgver=0.3.0rc1 pkgrel=1 pkgdesc="Spec-driven document rendering engine" arch=('x86_64' 'aarch64') @@ -9,7 +10,7 @@ url="https://github.com/egohygiene/renderflow" license=('MIT') depends=('pandoc') makedepends=('rust') -source=("$pkgname-$pkgver.tar.gz::https://github.com/egohygiene/renderflow/archive/refs/tags/v$pkgver.tar.gz") +source=("$pkgname-$pkgver.tar.gz::https://github.com/egohygiene/renderflow/archive/refs/tags/v0.3.0-rc.1.tar.gz") sha256sums=('PLACEHOLDER_SHA256') build() { diff --git a/pkg/chocolatey/renderflow.nuspec b/pkg/chocolatey/renderflow.nuspec index 328fd02..12f0da3 100644 --- a/pkg/chocolatey/renderflow.nuspec +++ b/pkg/chocolatey/renderflow.nuspec @@ -2,7 +2,7 @@ renderflow - 0.2.1 + 0.3.0-rc.1 renderflow Ego Hygiene https://github.com/egohygiene/renderflow @@ -10,7 +10,9 @@ false https://github.com/egohygiene/renderflow https://github.com/egohygiene/renderflow/issues - Spec-driven document rendering engine for transforming Markdown into PDF, HTML, and DOCX output. + UNPUBLISHED TEMPLATE: no verified Windows artifact or Chocolatey channel exists for this candidate. This package is not installable until reviewed release assets and checksums replace the placeholders. + +Spec-driven document rendering engine for transforming Markdown into PDF, HTML, and DOCX output. Define your output spec in YAML. Point it at your Markdown. Run one command. diff --git a/pkg/chocolatey/tools/chocolateyinstall.ps1 b/pkg/chocolatey/tools/chocolateyinstall.ps1 index 357a837..c2b6fe2 100644 --- a/pkg/chocolatey/tools/chocolateyinstall.ps1 +++ b/pkg/chocolatey/tools/chocolateyinstall.ps1 @@ -1,8 +1,9 @@ $ErrorActionPreference = 'Stop' -# url and checksum are replaced automatically by CI on each tagged release. +# UNPUBLISHED TEMPLATE: no verified Windows asset or Chocolatey channel exists. +# The placeholder checksum deliberately prevents installation. $toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$url64 = 'https://github.com/egohygiene/renderflow/releases/download/v0.2.1/renderflow-x86_64-pc-windows-msvc.exe' +$url64 = 'https://github.com/egohygiene/renderflow/releases/download/v0.3.0-rc.1/renderflow-x86_64-pc-windows-msvc.exe' Get-ChocolateyWebFile -PackageName 'renderflow' ` -FileFullPath "$toolsDir\renderflow.exe" ` diff --git a/pkg/scoop/renderflow.json b/pkg/scoop/renderflow.json index d4775a7..39dcbe0 100644 --- a/pkg/scoop/renderflow.json +++ b/pkg/scoop/renderflow.json @@ -1,27 +1,13 @@ { - "version": "0.2.1", - "description": "Spec-driven document rendering engine", + "version": "0.3.0-rc.1", + "description": "UNPUBLISHED TEMPLATE: Renderflow prerelease has no verified Windows asset or Scoop channel", "homepage": "https://github.com/egohygiene/renderflow", "license": "MIT", "architecture": { "64bit": { - "url": "https://github.com/egohygiene/renderflow/releases/download/v0.2.1/renderflow-x86_64-pc-windows-msvc.exe#/renderflow.exe", + "url": "https://github.com/egohygiene/renderflow/releases/download/v0.3.0-rc.1/renderflow-x86_64-pc-windows-msvc.exe#/renderflow.exe", "hash": "PLACEHOLDER_SHA256" } }, - "bin": "renderflow.exe", - "checkver": { - "github": "https://github.com/egohygiene/renderflow" - }, - "autoupdate": { - "architecture": { - "64bit": { - "url": "https://github.com/egohygiene/renderflow/releases/download/v$version/renderflow-x86_64-pc-windows-msvc.exe#/renderflow.exe", - "hash": { - "url": "https://github.com/egohygiene/renderflow/releases/download/v$version/renderflow-x86_64-pc-windows-msvc.exe.sha256", - "regex": "([a-fA-F0-9]+)" - } - } - } - } + "bin": "renderflow.exe" } diff --git a/release.toml b/release.toml index 5f83ec3..f40e17f 100644 --- a/release.toml +++ b/release.toml @@ -1,15 +1,14 @@ -# cargo-release configuration +# Legacy cargo-release configuration. The integration candidate follows +# docs/release-candidate.md: review/merge first, then a separately created +# annotated tag on the exact green main commit and manual tag-ref dispatch. # https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md -# Create a git tag for each release (e.g. v0.2.0) -tag = true +# Never create a tag as a side effect of a local cargo-release invocation. +tag = false # Tag name format tag-name = "v{{version}}" -# Tag message -tag-message = "chore(release): release version {{version}}" - # Do not push commits and tags to remote automatically push = false @@ -19,8 +18,5 @@ pre-release-commit-message = "chore(release): bump version to {{version}}" # Do not publish to crates.io by default publish = false -# Sign tags with GPG if available -sign-tag = false - # Allow dirty working directories (no staged changes required) allow-branch = ["main"] diff --git a/schemas/renderflow-release-manifest-v1.schema.json b/schemas/renderflow-release-manifest-v1.schema.json new file mode 100644 index 0000000..4610c05 --- /dev/null +++ b/schemas/renderflow-release-manifest-v1.schema.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-release-manifest-v1.schema.json", + "title": "Renderflow integration-candidate release lock v1", + "description": "A reviewed commit, exact CLI asset digest, contract matrix, and separately verifiable supply-chain evidence. A checksum is not a signature.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "repository", "version", "tag", "commit", "channel", "binary", "contracts", "compatibility", "assets", "security"], + "properties": { + "schema": { "const": "renderflow.release-manifest/v1" }, + "repository": { "const": "egohygiene/renderflow" }, + "version": { "$ref": "#/$defs/version" }, + "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+$" }, + "commit": { "$ref": "#/$defs/commit" }, + "channel": { "const": "integration-candidate" }, + "binary": { + "type": "object", "additionalProperties": false, + "required": ["name", "url", "target", "sha256", "size"], + "properties": { + "name": { "const": "renderflow-x86_64-unknown-linux-gnu" }, + "url": { "$ref": "#/$defs/asset_url" }, + "target": { "const": "x86_64-unknown-linux-gnu" }, + "sha256": { "$ref": "#/$defs/digest" }, + "size": { "type": "integer", "minimum": 1 } + } + }, + "contracts": { + "type": "object", "additionalProperties": false, + "required": ["cli_version", "core_crate_version", "plugin_sdk_crate_version", "plugin_contract", "provider_contract", "artifact_manifest_contract", "flow_artifact_contract", "tool_registry_contract", "capabilities", "schemas"], + "properties": { + "cli_version": { "$ref": "#/$defs/version" }, + "core_crate_version": { "$ref": "#/$defs/version" }, + "plugin_sdk_crate_version": { "$ref": "#/$defs/version" }, + "plugin_contract": { "const": "renderflow.plugin/v2alpha1" }, + "provider_contract": { "const": "renderflow.provider/v1" }, + "artifact_manifest_contract": { "const": "renderflow.artifact-manifest/v1" }, + "flow_artifact_contract": { "const": "flow.artifact/v1" }, + "tool_registry_contract": { "const": "renderflow.tool-registry/v1" }, + "capabilities": { + "type": "array", "minItems": 2, + "items": { + "type": "object", "additionalProperties": false, + "required": ["id", "provider_id", "availability", "input_kind", "ordered_collection", "source_mutation", "output_format", "required_external_tools"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "provider_id": { "type": "string", "minLength": 1 }, + "availability": { "enum": ["native", "requires_external_tool"] }, + "input_kind": { "const": "collection" }, + "ordered_collection": { "const": true }, + "source_mutation": { "const": false }, + "output_format": { "enum": ["pdf", "epub"] }, + "required_external_tools": { "type": "array", "items": { "type": "string", "minLength": 1 } } + } + } + }, + "schemas": { + "type": "array", "minItems": 5, + "items": { + "type": "object", "additionalProperties": false, + "required": ["name", "identifier", "sha256", "source_path"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "identifier": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/digest" }, + "source_path": { "type": "string", "pattern": "^schemas/[A-Za-z0-9_.-]+\\.json$" } + } + } + } + } + }, + "compatibility": { + "type": "object", "additionalProperties": false, + "required": ["supported_platforms", "verified_host", "gnu_libc_minimum", "other_platforms", "external_tools"], + "properties": { + "supported_platforms": { "type": "array", "minItems": 1, "maxItems": 1, "items": { "const": "x86_64-unknown-linux-gnu" } }, + "verified_host": { "const": "ubuntu-24.04-x86_64" }, + "gnu_libc_minimum": { "const": "2.39" }, + "other_platforms": { "const": "unsupported_unverified" }, + "external_tools": { + "type": "array", "items": { + "type": "object", "additionalProperties": false, + "required": ["name", "constraint", "required_for"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "constraint": { "type": "string", "minLength": 1 }, + "required_for": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "assets": { "type": "array", "minItems": 10, "items": { "$ref": "#/$defs/asset" } }, + "security": { + "type": "object", "additionalProperties": false, + "required": ["asset_signing", "provenance", "attestation_bundles"], + "properties": { + "asset_signing": { "const": "unsigned" }, + "provenance": { "const": "github_sigstore_bundle_attached_verify_separately" }, + "attestation_bundles": { + "type": "array", "minItems": 2, "maxItems": 2, + "items": { + "type": "object", "additionalProperties": false, + "required": ["subject", "bundle"], + "properties": { + "subject": { "type": "string", "minLength": 1 }, + "bundle": { "$ref": "#/$defs/asset_name" } + } + } + } + } + } + }, + "$defs": { + "digest": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "commit": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+$" }, + "asset_name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$" }, + "asset": { + "type": "object", "additionalProperties": false, + "required": ["name", "url", "kind", "sha256", "size"], + "properties": { + "name": { "$ref": "#/$defs/asset_name" }, + "url": { "$ref": "#/$defs/asset_url" }, + "kind": { "enum": ["cli-binary", "checksum", "spdx-sbom", "dependency-notices", "provenance-bundle", "sbom-attestation-bundle"] }, + "sha256": { "$ref": "#/$defs/digest" }, + "size": { "type": "integer", "minimum": 1 } + } + }, + "asset_url": { "type": "string", "pattern": "^https://github\\.com/egohygiene/renderflow/releases/download/v[0-9]+\\.[0-9]+\\.[0-9]+-[0-9A-Za-z.-]+/[A-Za-z0-9][A-Za-z0-9_.-]*$" } + } +} diff --git a/scripts/install.sh b/scripts/install.sh index 08c2a2b..ad6842a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -eu REPO="${RENDERFLOW_REPO:-egohygiene/renderflow}" -VERSION="${RENDERFLOW_VERSION:-latest}" +VERSION="${RENDERFLOW_VERSION:-}" INSTALL_DIR="${RENDERFLOW_INSTALL_DIR:-/usr/local/bin}" log() { @@ -28,7 +28,7 @@ download() { esac if need_cmd curl; then - curl --proto '=https' --tlsv1.2 -fsSL "$src" -o "$dest" + curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location "$src" --output "$dest" elif need_cmd wget; then wget -qO "$dest" "$src" else @@ -64,25 +64,20 @@ detect_target() { os="$(uname -s | tr '[:upper:]' '[:lower:]')" arch="$(uname -m)" - case "$os" in - linux) os_part="unknown-linux-gnu" ;; - darwin) os_part="apple-darwin" ;; - *) - err "unsupported operating system: $os" - exit 1 - ;; - esac - - case "$arch" in - x86_64|amd64) arch_part="x86_64" ;; - aarch64|arm64) arch_part="aarch64" ;; - *) - err "unsupported architecture: $arch" - exit 1 - ;; + if [ "$os" != "linux" ] || [ "$arch" != "x86_64" ]; then + err "no verified release binary for $os/$arch; only x86_64-unknown-linux-gnu is supported" + exit 1 + fi + libc="$(getconf GNU_LIBC_VERSION 2>/dev/null || true)" + case "$libc" in + "glibc "*) libc_version="${libc#glibc }" ;; + *) err "this candidate requires GNU libc 2.39 or newer"; exit 1 ;; esac - - printf '%s-%s' "$arch_part" "$os_part" + if ! printf '%s\n' "$libc_version" | awk -F. '{exit !($1 > 2 || ($1 == 2 && $2 >= 39))}'; then + err "this candidate requires GNU libc 2.39 or newer (found $libc_version)" + exit 1 + fi + printf '%s' "x86_64-unknown-linux-gnu" } resolve_base_url() { @@ -91,18 +86,22 @@ resolve_base_url() { return fi - if [ "$VERSION" = "latest" ]; then - printf 'https://github.com/%s/releases/latest/download' "$REPO" - else - case "$VERSION" in - v*) tag="$VERSION" ;; - *) tag="v$VERSION" ;; - esac - printf 'https://github.com/%s/releases/download/%s' "$REPO" "$tag" - fi + printf 'https://github.com/%s/releases/download/%s' "$REPO" "$tag" } main() { + case "$VERSION" in + ""|latest|*[!a-zA-Z0-9.+-]*) + err "set RENDERFLOW_VERSION to an exact release tag (for example v0.3.0-rc.1)" + exit 1 + ;; + v*) tag="$VERSION"; expected_version="${VERSION#v}" ;; + *) tag="v$VERSION"; expected_version="$VERSION" ;; + esac + if ! printf '%s\n' "$expected_version" | LC_ALL=C grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$'; then + err "RENDERFLOW_VERSION is not an exact SemVer release tag" + exit 1 + fi target="$(detect_target)" base_url="$(resolve_base_url)" asset="renderflow-$target" @@ -115,12 +114,24 @@ main() { checksum_path="$tmp_dir/$checksum_asset" log "Installing Renderflow for target: $target" + log "Candidate verified on Ubuntu 24.04 x86_64 GNU; other distro baselines remain unverified." log "Downloading: $base_url/$asset" download "$base_url/$asset" "$bin_path" log "Downloading checksum: $base_url/$checksum_asset" download "$base_url/$checksum_asset" "$checksum_path" - expected="$(awk '{print $1}' "$checksum_path")" + if [ "$(wc -l < "$checksum_path" | tr -d ' ')" != "1" ]; then + err "checksum file must have exactly one newline-terminated entry" + exit 1 + fi + read -r expected checksum_name extra < "$checksum_path" + case "$expected" in + *[!0-9a-f]*|"") err "checksum is not lowercase SHA-256"; exit 1 ;; + esac + if [ "${#expected}" -ne 64 ] || [ "$checksum_name" != "$asset" ] || [ -n "${extra:-}" ]; then + err "checksum record does not identify the exact release asset" + exit 1 + fi actual="$(checksum_file "$bin_path")" if [ "$expected" != "$actual" ]; then err "checksum verification failed for $asset" @@ -130,6 +141,13 @@ main() { fi log "Checksum verification passed." + chmod 0755 "$bin_path" + observed_version="$("$bin_path" --version)" + if [ "$observed_version" != "renderflow $expected_version" ]; then + err "downloaded binary version differs from pinned release: $observed_version" + exit 1 + fi + if ! is_install_dir_writable "$INSTALL_DIR" && [ -z "${RENDERFLOW_INSTALL_DIR:-}" ]; then INSTALL_DIR="${HOME}/.local/bin" log "No write access to /usr/local/bin; falling back to $INSTALL_DIR" @@ -138,10 +156,7 @@ main() { mkdir -p "$INSTALL_DIR" install -m 0755 "$bin_path" "$INSTALL_DIR/renderflow" log "Installed renderflow to $INSTALL_DIR/renderflow" - - if command -v "$INSTALL_DIR/renderflow" >/dev/null 2>&1; then - "$INSTALL_DIR/renderflow" --version || true - fi + log "$observed_version" } main "$@" diff --git a/scripts/release/release_assets.py b/scripts/release/release_assets.py new file mode 100644 index 0000000..9cc5c3d --- /dev/null +++ b/scripts/release/release_assets.py @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 +"""Create and independently verify the bounded Renderflow release receipt. + +Only prepare/manifest reads the source checkout. verify consumes downloaded assets. +Checksum verification is separate from optional GitHub/Sigstore trust verification. +""" + +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import tomllib +import zlib + + +REPOSITORY = "egohygiene/renderflow" +TARGET = "x86_64-unknown-linux-gnu" +SCHEMA = "renderflow.release-manifest/v1" +MANIFEST = "renderflow-release-manifest-v1.json" +SBOM = "renderflow-sbom.spdx.json" +NOTICES = "THIRD_PARTY_NOTICES.txt" +ATTESTATION = "renderflow-attestation.json" +SBOM_ATTESTATION = "renderflow-sbom-attestation.json" +SCHEMAS = { + "execution_spec": ("renderflow/v2", "renderflow-v2.schema.json"), + "run_evidence": ("renderflow.run/v1", "renderflow-run-v1.schema.json"), + "provider": ("renderflow.provider/v1", "renderflow-provider-v1.schema.json"), + "plugin": ("renderflow.plugin/v2alpha1", "renderflow-plugin-v2alpha1.schema.json"), + "ebook_evidence": ("renderflow.ebook-evidence/v1", "renderflow-ebook-evidence-v1.schema.json"), + "ebook_capabilities": ("renderflow.ebook-capabilities/v1", "renderflow-ebook-capabilities-v1.schema.json"), + "release_manifest": (SCHEMA, "renderflow-release-manifest-v1.schema.json"), +} +HEX40 = re.compile(r"[0-9a-f]{40}\Z") +HEX64 = re.compile(r"[0-9a-f]{64}\Z") +VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+\Z") +NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z") +MAX_ASSET = 512 * 1024 * 1024 + + +def fail(message: str) -> None: + raise ValueError(message) + + +def require(condition: bool, message: str) -> None: + if not condition: + fail(message) + + +def digest(path: Path) -> str: + hasher = hashlib.sha256() + with path.open("rb") as reader: + for block in iter(lambda: reader.read(1024 * 1024), b""): + hasher.update(block) + return hasher.hexdigest() + + +def asset(root: Path, name: str) -> Path: + require(bool(NAME.fullmatch(name)), f"unsafe asset name: {name}") + path = root / name + require(path.is_file() and not path.is_symlink(), f"missing or symlinked asset: {name}") + require(0 < path.stat().st_size <= MAX_ASSET, f"empty or oversized asset: {name}") + return path + + +def put_text(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + + +def put_json(path: Path, value: object) -> None: + put_text(path, json.dumps(value, sort_keys=True, indent=2, ensure_ascii=False) + "\n") + + +def checksum(root: Path, name: str) -> None: + put_text(root / f"{name}.sha256", f"{digest(asset(root, name))} {name}\n") + + +def check_checksum(root: Path, name: str) -> None: + line = asset(root, f"{name}.sha256").read_text(encoding="ascii") + require(line == f"{digest(asset(root, name))} {name}\n", f"checksum mismatch or malformed record: {name}") + + +def identity(args: argparse.Namespace) -> None: + require(bool(VERSION.fullmatch(args.version)), "candidate version must be SemVer prerelease") + require(args.tag == f"v{args.version}", "version/tag mismatch") + require(bool(HEX40.fullmatch(args.commit)), "commit must be an exact lowercase 40-hex SHA") + require(args.target == TARGET, "only the verified Linux x86_64 GNU target is supported") + require(args.repository == REPOSITORY, "repository mismatch") + + +def source_version(source: Path, version: str) -> None: + cargo = tomllib.loads((source / "Cargo.toml").read_text(encoding="utf-8")) + require(cargo["workspace"]["package"]["version"] == version, "workspace Cargo version differs from release") + + +def metadata_for(source: Path, metadata_file: str | None) -> dict: + if metadata_file: + return json.loads(Path(metadata_file).read_text(encoding="utf-8")) + command = ["cargo", "metadata", "--locked", "--offline", "--format-version", "1", "--manifest-path", str(source / "Cargo.toml")] + proc = subprocess.run(command, capture_output=True, text=True, check=False, timeout=180) + require(proc.returncode == 0, f"offline cargo metadata failed: {proc.stderr[:1000]}") + return json.loads(proc.stdout) + + +def locked_packages(source: Path, metadata: dict) -> list[dict]: + locked = tomllib.loads((source / "Cargo.lock").read_text(encoding="utf-8"))["package"] + lock_index = {(p["name"], p["version"]): p for p in locked} + packages = metadata.get("packages") + require(isinstance(packages, list) and packages, "cargo metadata has no packages") + result = [] + for item in packages: + name, version = item["name"], item["version"] + lock = lock_index.get((name, version)) + require(lock is not None, f"metadata package is absent from Cargo.lock: {name} {version}") + if lock.get("checksum"): + require(bool(HEX64.fullmatch(lock["checksum"])), f"bad lock checksum: {name}") + result.append({ + "name": name, + "version": version, + "license": item.get("license") or "NOASSERTION", + "repository": item.get("repository") or "", + "source": item.get("source") or "workspace", + "checksum": lock.get("checksum"), + "manifest_path": item.get("manifest_path") or "", + }) + return sorted(result, key=lambda p: (p["name"], p["version"], p["source"])) + + +def license_texts(package: dict) -> list[tuple[str, str]]: + """Include bounded license texts when cached; otherwise do not invent them.""" + path = Path(package["manifest_path"]) + if not path.is_file(): + return [] + root = path.parent.resolve() + names = sorted(p for p in root.iterdir() if re.match(r"(?i)^(license|licence|copying|notice)([._-].*)?$", p.name)) + result = [] + for candidate in names: + if candidate.is_symlink() or not candidate.is_file() or candidate.stat().st_size > 128 * 1024: + continue + if candidate.resolve().parent != root: + continue + result.append((candidate.name, candidate.read_text(encoding="utf-8", errors="replace"))) + return result + + +def prepare(args: argparse.Namespace) -> None: + identity(args) + source = Path(args.source_dir).resolve() + source_version(source, args.version) + root = Path(args.artifact_dir).resolve() + root.mkdir(parents=True, exist_ok=True) + binary = f"renderflow-{args.target}" + asset(root, binary) + metadata = metadata_for(source, args.metadata_file) + packages = locked_packages(source, metadata) + spdx_packages = [] + relationships = [] + for index, package in enumerate(packages, 1): + spdx_id = f"SPDXRef-Package-{index}" + entry = { + "SPDXID": spdx_id, + "name": package["name"], + "versionInfo": package["version"], + "downloadLocation": "NOASSERTION", + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": package["license"], + "copyrightText": "NOASSERTION", + "externalRefs": [{ + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": f"pkg:cargo/{package['name']}@{package['version']}", + }], + } + if package["checksum"]: + entry["checksums"] = [{"algorithm": "SHA256", "checksumValue": package["checksum"]}] + spdx_packages.append(entry) + relationships.append({"spdxElementId": "SPDXRef-DOCUMENT", "relatedSpdxElement": spdx_id, "relationshipType": "DESCRIBES"}) + created = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + if os.environ.get("SOURCE_DATE_EPOCH"): + created = dt.datetime.fromtimestamp(int(os.environ["SOURCE_DATE_EPOCH"]), dt.timezone.utc).isoformat().replace("+00:00", "Z") + put_json(root / SBOM, { + "spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", + "name": f"Renderflow workspace Cargo lock {args.version}", + "documentNamespace": f"https://github.com/{REPOSITORY}/releases/tag/{args.tag}/spdx-{args.commit}", + "creationInfo": {"created": created, "creators": ["Tool: scripts/release/release_assets.py"]}, + "comment": "Cargo workspace locked dependency inventory, including optional and development dependencies. This is not a binary composition claim.", + "packages": spdx_packages, "relationships": relationships, + }) + notices = [ + f"Renderflow {args.version} — Cargo workspace dependency/license notices", + f"Source commit: {args.commit}", + "This inventory includes optional and development dependencies from Cargo.lock.", + "License expressions are declared by upstream packages, not independently adjudicated.", + "Bundled cached license text is included below when available; NOASSERTION or", + "missing text requires upstream review. This file does not grant new rights.", + "", + ] + total_license_bytes = 0 + for package in packages: + if package["source"] == "workspace": + continue + notices += [f"{package['name']} {package['version']}", f"Declared license: {package['license']}", f"Source: {package['repository'] or package['source']}"] + for filename, content in license_texts(package): + total_license_bytes += len(content.encode("utf-8")) + require(total_license_bytes <= 8 * 1024 * 1024, "cached license notice total is too large") + notices += [f"--- {filename} ---", content.rstrip(), f"--- end {filename} ---"] + notices += [""] + put_text(root / NOTICES, "\n".join(notices)) + for name in (binary, SBOM, NOTICES): + checksum(root, name) + print(f"Prepared {binary}, checksum, SPDX inventory, and dependency notices in {root}") + + +def release_url(tag: str, name: str) -> str: + return f"https://github.com/{REPOSITORY}/releases/download/{tag}/{name}" + + +def attestation_bundle(root: Path, provided: str, fixed_name: str, expected_subject: str, expected_digest: str) -> None: + original = Path(provided).resolve() + require(original.is_file() and original.stat().st_size > 0, f"missing attestation bundle: {provided}") + path = root / fixed_name + require(not path.is_symlink(), f"symlinked destination bundle refused: {fixed_name}") + if original != path: + shutil.copyfile(original, path) + bundle = json.loads(asset(root, fixed_name).read_text(encoding="utf-8")) + require(isinstance(bundle, dict), f"invalid attestation bundle: {fixed_name}") + envelope = bundle.get("dsseEnvelope") or bundle.get("dsse_envelope") + require(isinstance(envelope, dict) and envelope.get("payload") and envelope.get("signatures"), f"missing signed DSSE envelope: {fixed_name}") + require(bundle.get("verificationMaterial") or bundle.get("verification_material"), f"missing verification material: {fixed_name}") + statement = json.loads(base64.b64decode(envelope["payload"], validate=True)) + subjects = statement.get("subject", []) + require(any( + isinstance(s, dict) + and isinstance(s.get("name"), str) + and (s["name"] == expected_subject or s["name"].endswith(f"/{expected_subject}")) + and isinstance(s.get("digest"), dict) + and s["digest"].get("sha256") == expected_digest + for s in subjects + ), f"attestation subject mismatch: {fixed_name}") + if fixed_name == SBOM_ATTESTATION: + require(statement.get("predicateType") == "https://spdx.dev/Document/v2.3", "SBOM attestation predicate type mismatch") + require(statement.get("predicate") == json.loads(asset(root, SBOM).read_text(encoding="utf-8")), "attached SBOM does not match signed predicate") + else: + require(statement.get("predicateType") == "https://slsa.dev/provenance/v1", "build provenance predicate type mismatch") + # The contents remain untrusted until gh attestation verify checks signatures. + + +def manifest(args: argparse.Namespace) -> None: + identity(args) + source = Path(args.source_dir).resolve() + source_version(source, args.version) + root = Path(args.artifact_dir).resolve() + binary = f"renderflow-{args.target}" + for name in (binary, SBOM, NOTICES): + check_checksum(root, name) + attestation_bundle(root, args.attestation_bundle, ATTESTATION, binary, digest(asset(root, binary))) + attestation_bundle(root, args.sbom_attestation_bundle, SBOM_ATTESTATION, binary, digest(asset(root, binary))) + checksum(root, ATTESTATION) + checksum(root, SBOM_ATTESTATION) + names = [binary, f"{binary}.sha256", SBOM, f"{SBOM}.sha256", NOTICES, f"{NOTICES}.sha256", ATTESTATION, f"{ATTESTATION}.sha256", SBOM_ATTESTATION, f"{SBOM_ATTESTATION}.sha256"] + kinds = ["cli-binary", "checksum", "spdx-sbom", "checksum", "dependency-notices", "checksum", "provenance-bundle", "checksum", "sbom-attestation-bundle", "checksum"] + items = [] + for name, kind in zip(names, kinds, strict=True): + path = asset(root, name) + items.append({"name": name, "url": release_url(args.tag, name), "kind": kind, "sha256": digest(path), "size": path.stat().st_size}) + schemas = [] + for name, (identifier, filename) in SCHEMAS.items(): + path = source / "schemas" / filename + require(path.is_file(), f"missing source schema: {filename}") + schemas.append({"name": name, "identifier": identifier, "sha256": digest(path), "source_path": f"schemas/{filename}"}) + receipt = { + "schema": SCHEMA, "repository": REPOSITORY, "version": args.version, + "tag": args.tag, "commit": args.commit, "channel": "integration-candidate", + "binary": {"name": binary, "url": release_url(args.tag, binary), "target": args.target, "sha256": digest(root / binary), "size": (root / binary).stat().st_size}, + "contracts": { + "cli_version": args.version, + "core_crate_version": args.version, + "plugin_sdk_crate_version": args.version, + "plugin_contract": "renderflow.plugin/v2alpha1", + "provider_contract": "renderflow.provider/v1", + "artifact_manifest_contract": "renderflow.artifact-manifest/v1", + "flow_artifact_contract": "flow.artifact/v1", + "tool_registry_contract": "renderflow.tool-registry/v1", + "capabilities": [ + {"id": "publication.generate.pdf.interior", "provider_id": "tool.img2pdf", "availability": "requires_external_tool", "input_kind": "collection", "ordered_collection": True, "source_mutation": False, "output_format": "pdf", "required_external_tools": ["img2pdf 0.6.3"]}, + {"id": "ebook.generate.epub.fixed-layout", "provider_id": "tool.renderflow-epub", "availability": "native", "input_kind": "collection", "ordered_collection": True, "source_mutation": False, "output_format": "epub", "required_external_tools": []}, + ], + "schemas": schemas, + }, + "compatibility": { + "supported_platforms": [TARGET], "verified_host": "ubuntu-24.04-x86_64", + "gnu_libc_minimum": "2.39", "other_platforms": "unsupported_unverified", + "external_tools": [ + {"name": "img2pdf", "constraint": "==0.6.3", "required_for": "publication.generate.pdf.interior"}, + {"name": "epubcheck", "constraint": "v5, optional; exact version recorded per inspection", "required_for": "optional independent EPUBCheck evidence"}, + {"name": "pandoc", "constraint": ">=2.0.0, optional", "required_for": "provider-backed document conversion"}, + ], + }, + "assets": items, + "security": { + "asset_signing": "unsigned", "provenance": "github_sigstore_bundle_attached_verify_separately", + "attestation_bundles": [{"subject": binary, "bundle": ATTESTATION}, {"subject": binary, "bundle": SBOM_ATTESTATION}], + }, + } + put_json(root / MANIFEST, receipt) + checksum(root, MANIFEST) + print(f"Created {MANIFEST} and checksum; binary SHA-256 {receipt['binary']['sha256']}") + + +def run_binary(binary: Path, cwd: Path, args: list[str], expect_success: bool = True) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["HOME"] = str(cwd / "home") + command = [str(binary), *args] + result = subprocess.run(command, cwd=cwd, env=env, text=True, capture_output=True, timeout=60, check=False) + require((result.returncode == 0) == expect_success, f"smoke {args}: unexpected exit {result.returncode}: {result.stderr[:1000]}") + return result + + +def png(color: tuple[int, int, int]) -> bytes: + """Small, reproducible RGB synthetic art; no source tree fixture dependency.""" + def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xffffffff) + pixel_rows = b"".join(b"\0" + bytes(color) * 100 for _ in range(100)) + return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 100, 100, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(pixel_rows, 9)) + chunk(b"IEND", b"") + + +def smoke_pdf(binary: Path, root: Path) -> None: + """Exercise the advertised exact provider route on copied synthetic files.""" + provider = shutil.which("img2pdf") + require(provider is not None, "release PDF smoke requires real img2pdf 0.6.3") + version = subprocess.run([provider, "--version"], capture_output=True, text=True, timeout=10, check=False) + require(version.returncode == 0 and version.stdout.strip() == "img2pdf 0.6.3", "PDF provider must be exact img2pdf 0.6.3") + provider = str(Path(provider).resolve()) + pdf_work = root / "pdf-fixture" + pdf_work.mkdir() + (pdf_work / "home").mkdir() + images = [png((30, 70, 130)), png((180, 90, 40))] + for index, data in enumerate(images): + (pdf_work / f"page-{index:03}.png").write_bytes(data) + sources = "".join( + f" - id: source.page{index:03}\n path: page-{index:03}.png\n" + f" format: png\n media_type: image/png\n sha256: \"{hashlib.sha256(data).hexdigest()}\"\n" + " geometry: { width: 90, height: 90, unit: mm, bleed: 5 }\n" + for index, data in enumerate(images) + ) + config = ( + "schema: renderflow/v2\nsources:\n" + sources + + " - id: source.pages\n kind: collection\n members: [source.page000, source.page001]\n" + "targets:\n exact:\n - id: target.interior\n role: interior\n" + " format: pdf\n requirement: required\n" + "execution:\n print_pdf_interior:\n" + f" executable: \"{provider}\"\n provider_version: \"0.6.3\"\n" + " box_policy: media_bleed_trim_inset\n rotation: none\n scaling: fit\n" + " color_policy: preserve_rgb_gray\n max_pages: 2\n" + " max_input_bytes: 1000000\n max_output_bytes: 5000000\n" + " timeout_seconds: 15\noutput:\n bundle_root: dist\n" + " naming_template: \"{source.id}/{target.role}.{ext}\"\n" + ) + (pdf_work / "renderflow.yaml").write_text(config, encoding="utf-8") + run_binary(binary, pdf_work, ["spec", "validate", "--config", "renderflow.yaml"]) + run_binary(binary, pdf_work, ["build", "--config", "renderflow.yaml", "--dry-run"]) + run_binary(binary, pdf_work, ["build", "--config", "renderflow.yaml"]) + output = pdf_work / "dist" / "source.pages" / "interior.pdf" + require(output.is_file() and output.read_bytes().startswith(b"%PDF-"), "PDF fixture output missing or malformed") + evidence = json.loads((pdf_work / "dist" / "renderflow-run.json").read_text(encoding="utf-8")) + require(evidence.get("state") == "complete", "PDF run manifest not complete") + artifacts = evidence["artifact_manifest"]["artifacts"] + sources = [item for item in artifacts if item.get("lifecycle") == "source"] + interior = next((item for item in artifacts if item.get("role") == "interior"), None) + require(len(sources) == 2 and interior is not None, "PDF source/output lineage missing") + require([source["metadata"]["renderflow.collection.index"] for source in sources] == [0, 1], "PDF source order mismatch") + require(interior["sources"] == [source["artifact_id"] for source in sources], "PDF artifact lineage mismatch") + require(interior["validation"] == "valid", "PDF artifact was not validated") + inspected = interior["metadata"]["renderflow.print_pdf.inspection"] + require(inspected["page_count"] == 2 and len(inspected["pages"]) == 2, "independent PDF inspection page count mismatch") + require(inspected["sha256"] == digest(output), "independent PDF inspection digest mismatch") + require(inspected["pages"][0]["image_stream_sha256"] != inspected["pages"][1]["image_stream_sha256"], "PDF page streams not distinct") + require(all((pdf_work / f"page-{i:03}.png").read_bytes() == original for i, original in enumerate(images)), "PDF source bytes mutated") + try: + import pikepdf + except ImportError as error: + fail(f"independent PDF parser unavailable with img2pdf installation: {error}") + with pikepdf.open(output) as document: + require(len(document.pages) == 2, "pikepdf found a different page count") + for page in document.pages: + require(len(page.MediaBox) == 4 and len(page.TrimBox) == 4 and len(page.BleedBox) == 4, "PDF page boxes missing") + # A changed frozen source must be refused before publishing another PDF. + (pdf_work / "page-001.png").write_bytes(b"source changed after plan") + (pdf_work / "dist").rename(pdf_work / "completed-dist") + run_binary(binary, pdf_work, ["build", "--config", "renderflow.yaml"], expect_success=False) + require(not (pdf_work / "dist" / "source.pages" / "interior.pdf").exists(), "stale PDF source published an artifact") + + +def smoke(binary: Path, version: str) -> None: + with tempfile.TemporaryDirectory(prefix="renderflow-release-smoke-") as temporary: + root = Path(temporary) + install = root / "install" + install.mkdir() + # Move executable into an otherwise empty installation: no worktree paths. + installed = install / "renderflow" + shutil.copy2(binary, installed) + installed.chmod(0o755) + work = root / "fixture" + work.mkdir() + (work / "home").mkdir() + require(run_binary(installed, work, ["--version"]).stdout.strip() == f"renderflow {version}", "binary version mismatch") + require("build" in run_binary(installed, work, ["--help"]).stdout, "top-level help missing build") + require("Renderflow Doctor" in run_binary(installed, work, ["doctor"]).stdout, "doctor evidence missing") + hashes = [] + for index, color in enumerate(((30, 70, 130), (180, 90, 40)), 1): + filename = f"page-{index:03}.png" + data = png(color) + (work / filename).write_bytes(data) + hashes.append(hashlib.sha256(data).hexdigest()) + sources = "".join(f" - id: source.page{i:03}\n path: page-{i:03}.png\n format: png\n media_type: image/png\n sha256: \"{hashes[i-1]}\"\n geometry: {{ width: 90, height: 90, unit: mm }}\n" for i in (1, 2)) + artwork = "".join(f" - role: page\n path: page-{i:03}.png\n alt_text: Synthetic colored geometric page {i}.\n" for i in (1, 2)) + spec = ( + "schema: renderflow/v2\nsources:\n" + sources + + " - id: source.pages\n kind: collection\n members: [source.page001, source.page002]\n" + "publication:\n publication: Synthetic release smoke\n issue_id: release-smoke\n" + " title: Synthetic release pages\n contributors:\n - name: Renderflow contributors\n role: author\n" + " publication_date: \"2026-09-28\"\n language: en-US\n" + " geometry: { width: 90, height: 90, unit: mm }\n artwork:\n" + artwork + + " rights:\n license: CC0-1.0\n rights_holder: Renderflow contributors\n" + " accessibility:\n summary: Two synthetic colored pages, each with a description.\n" + " access_modes: [visual]\n hazards: [none]\n" + "targets:\n exact:\n - id: target.ebook\n role: ebook\n" + " format: epub\n requirement: required\n" + "execution:\n fixed_layout_epub:\n page_progression_direction: ltr\n" + " spread: none\n cover_member_id: source.page001\n max_pages: 2\n" + " max_input_bytes: 1000000\n max_output_bytes: 5000000\n" + "output:\n bundle_root: dist\n" + ) + (work / "renderflow.yaml").write_text(spec, encoding="utf-8") + run_binary(installed, work, ["spec", "validate", "--config", "renderflow.yaml"]) + preview = run_binary(installed, work, ["build", "--config", "renderflow.yaml", "--dry-run"]) + require(preview.stdout.strip(), "planning did not emit a dry-run plan") + run_binary(installed, work, ["build", "--config", "renderflow.yaml"]) + epub = work / "dist" / "source.pages" / "ebook.epub" + require(epub.is_file(), "native fixture execution did not generate EPUB") + inspected = run_binary(installed, work, ["ebook", "inspect", "--input", str(epub), "--run-manifest", str(work / "dist" / "renderflow-run.json"), "--fixed-layout", "--format", "json"]) + evidence = json.loads(inspected.stdout) + require(evidence.get("valid") is True and evidence.get("fixed_layout", {}).get("status") == "validated" and evidence.get("provenance", {}).get("status") == "verified", "native EPUB inspection or provenance failed") + run_binary(installed, work, ["spec", "validate", "--config", "missing.yaml"], expect_success=False) + smoke_pdf(installed, root) + print("Clean installation smoke: version, help, doctor, plan, native EPUB, exact-provider PDF, independent inspections, and failure exits passed") + + +def verify(args: argparse.Namespace) -> None: + identity(args) + root = Path(args.artifact_dir).resolve() + check_checksum(root, MANIFEST) + receipt = json.loads(asset(root, MANIFEST).read_text(encoding="utf-8")) + for key, expected in (("schema", SCHEMA), ("repository", args.repository), ("version", args.version), ("tag", args.tag), ("commit", args.commit), ("channel", "integration-candidate")): + require(receipt.get(key) == expected, f"release manifest {key} mismatch") + binary = f"renderflow-{TARGET}" + binary_info = receipt["binary"] + require(binary_info["name"] == binary and binary_info["target"] == TARGET and binary_info["url"] == release_url(args.tag, binary), "binary lock mismatch") + require( + receipt["compatibility"]["supported_platforms"] == [TARGET] + and receipt["compatibility"]["verified_host"] == "ubuntu-24.04-x86_64" + and receipt["compatibility"]["gnu_libc_minimum"] == "2.39" + and receipt["compatibility"]["other_platforms"] == "unsupported_unverified", + "platform/libc baseline mismatch", + ) + expected_tools = [ + {"name": "img2pdf", "constraint": "==0.6.3", "required_for": "publication.generate.pdf.interior"}, + {"name": "epubcheck", "constraint": "v5, optional; exact version recorded per inspection", "required_for": "optional independent EPUBCheck evidence"}, + {"name": "pandoc", "constraint": ">=2.0.0, optional", "required_for": "provider-backed document conversion"}, + ] + require(receipt["compatibility"]["external_tools"] == expected_tools, "external-tool compatibility mismatch") + require(receipt["security"]["asset_signing"] == "unsigned" and receipt["security"]["provenance"] == "github_sigstore_bundle_attached_verify_separately", "signing/provenance status mismatch") + require(all(receipt["contracts"][field] == args.version for field in ("cli_version", "core_crate_version", "plugin_sdk_crate_version")), "contract version mismatch") + require(receipt["contracts"]["plugin_contract"] == "renderflow.plugin/v2alpha1" and receipt["contracts"]["provider_contract"] == "renderflow.provider/v1", "contract identifier mismatch") + for field, expected in (("artifact_manifest_contract", "renderflow.artifact-manifest/v1"), + ("flow_artifact_contract", "flow.artifact/v1"), + ("tool_registry_contract", "renderflow.tool-registry/v1")): + require(receipt["contracts"][field] == expected, f"{field} mismatch") + required_capabilities = {"publication.generate.pdf.interior": ("tool.img2pdf", "pdf", "requires_external_tool", ["img2pdf 0.6.3"]), "ebook.generate.epub.fixed-layout": ("tool.renderflow-epub", "epub", "native", [])} + capabilities = {item["id"]: item for item in receipt["contracts"]["capabilities"]} + require(set(capabilities) == set(required_capabilities), "release capability set mismatch") + for name, (provider, output, availability, tools) in required_capabilities.items(): + item = capabilities[name] + require(item["provider_id"] == provider and item["output_format"] == output and item["availability"] == availability and item["required_external_tools"] == tools and item["ordered_collection"] is True and item["source_mutation"] is False, f"capability effects/availability mismatch: {name}") + schemas = {item["name"]: item for item in receipt["contracts"]["schemas"]} + require(set(schemas) == set(SCHEMAS), "schema set mismatch") + for name, (identifier, filename) in SCHEMAS.items(): + item = schemas[name] + require(item["identifier"] == identifier and item["source_path"] == f"schemas/{filename}" and bool(HEX64.fullmatch(item["sha256"])), f"schema contract mismatch: {name}") + expected_names = {binary, f"{binary}.sha256", SBOM, f"{SBOM}.sha256", NOTICES, f"{NOTICES}.sha256", ATTESTATION, f"{ATTESTATION}.sha256", SBOM_ATTESTATION, f"{SBOM_ATTESTATION}.sha256"} + expected_kinds = dict(zip( + [binary, f"{binary}.sha256", SBOM, f"{SBOM}.sha256", NOTICES, f"{NOTICES}.sha256", ATTESTATION, f"{ATTESTATION}.sha256", SBOM_ATTESTATION, f"{SBOM_ATTESTATION}.sha256"], + ["cli-binary", "checksum", "spdx-sbom", "checksum", "dependency-notices", "checksum", "provenance-bundle", "checksum", "sbom-attestation-bundle", "checksum"], strict=True, + )) + entries = receipt["assets"] + require(len(entries) == len(expected_names) and {entry["name"] for entry in entries} == expected_names, "asset list incomplete or duplicated") + for entry in entries: + path = asset(root, entry["name"]) + require(entry["kind"] == expected_kinds[entry["name"]], f"asset role mismatch: {entry['name']}") + require(entry["url"] == release_url(args.tag, entry["name"]), f"asset URL mismatch: {entry['name']}") + require(entry["size"] == path.stat().st_size and entry["sha256"] == digest(path), f"asset digest/size mismatch: {entry['name']}") + require(binary_info["sha256"] == digest(asset(root, binary)) and binary_info["size"] == (root / binary).stat().st_size, "binary lock digest mismatch") + for name in (binary, SBOM, NOTICES, ATTESTATION, SBOM_ATTESTATION): + check_checksum(root, name) + for bundle, subject in ((ATTESTATION, binary), (SBOM_ATTESTATION, binary)): + attestation_bundle(root, str(root / bundle), bundle, subject, binary_info["sha256"]) + require(receipt["security"]["attestation_bundles"] == [{"subject": binary, "bundle": ATTESTATION}, {"subject": binary, "bundle": SBOM_ATTESTATION}], "attestation link mismatch") + if args.verify_attestations: + for bundle in (ATTESTATION, SBOM_ATTESTATION): + command = [ + "gh", "attestation", "verify", str(root / binary), + "--repo", REPOSITORY, + "--bundle", str(root / bundle), + "--source-ref", f"refs/tags/{args.tag}", + "--source-digest", args.commit, + "--signer-workflow", f"{REPOSITORY}/.github/workflows/release.yml", + ] + if bundle == SBOM_ATTESTATION: + command += ["--predicate-type", "https://spdx.dev/Document/v2.3"] + result = subprocess.run(command, capture_output=True, text=True, timeout=120, check=False) + require(result.returncode == 0, f"Sigstore/GitHub attestation verification failed ({bundle}): {result.stderr[:1000]}") + print("GitHub/Sigstore signatures verified for both attached bundles") + if args.smoke: + smoke(root / binary, args.version) + if args.installer_script: + installer = Path(args.installer_script).resolve() + require(installer.is_file(), "installer script missing") + with tempfile.TemporaryDirectory(prefix="renderflow-installer-smoke-") as temporary: + install = Path(temporary) / "bin" + env = os.environ.copy() + env.update({"RENDERFLOW_VERSION": args.tag, "RENDERFLOW_DOWNLOAD_BASE_URL": root.as_uri(), "RENDERFLOW_INSTALL_DIR": str(install)}) + result = subprocess.run(["sh", str(installer)], env=env, capture_output=True, text=True, timeout=60, check=False) + require(result.returncode == 0, f"file:// pinned installer smoke failed: {result.stderr[:1000]}") + require(digest(install / "renderflow") == binary_info["sha256"], "installer binary differs from pinned release") + print(f"Verified downloaded release assets and Flow provider lock: {args.tag} {args.commit} {binary_info['sha256']}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + for action in ("prepare", "manifest", "verify"): + cmd = commands.add_parser(action) + cmd.add_argument("--artifact-dir", required=True) + cmd.add_argument("--version", required=True) + cmd.add_argument("--tag", required=True) + cmd.add_argument("--commit", required=True) + cmd.add_argument("--target", required=True) + cmd.add_argument("--repository", default=REPOSITORY) + if action != "verify": + cmd.add_argument("--source-dir", default=str(Path(__file__).resolve().parents[2])) + if action == "prepare": + cmd.add_argument("--metadata-file", help="Fixture-only, or a precomputed cargo metadata JSON file") + if action == "manifest": + cmd.add_argument("--attestation-bundle", required=True) + cmd.add_argument("--sbom-attestation-bundle", required=True) + if action == "verify": + cmd.add_argument("--verify-attestations", action="store_true", help="Cryptographically verify both bundles via gh (network/trust root required)") + cmd.add_argument("--smoke", action="store_true", help="Run isolated installed binary and a native synthetic EPUB fixture") + cmd.add_argument("--installer-script", help="Test scripts/install.sh via file:// pinned assets in a clean temporary install") + args = parser.parse_args() + try: + {"prepare": prepare, "manifest": manifest, "verify": verify}[args.command](args) + except (ValueError, KeyError, OSError, json.JSONDecodeError, subprocess.TimeoutExpired) as error: + print(f"release receipt {args.command}: {error}", file=sys.stderr) + raise SystemExit(1) from error + + +if __name__ == "__main__": + main() diff --git a/scripts/release/test_release_assets.py b/scripts/release/test_release_assets.py new file mode 100644 index 0000000..ef77482 --- /dev/null +++ b/scripts/release/test_release_assets.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Offline synthetic release receipt, tamper, and installer refusal tests.""" + +import base64 +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import tempfile +import unittest + +import release_assets as release + + +SCRIPT = Path(__file__).resolve().parent / "release_assets.py" +REPO = SCRIPT.parents[2] +VERSION = "0.3.0-rc.1" +TAG = f"v{VERSION}" +COMMIT = "a" * 40 + + +def call(action: str, root: Path, *extra: str, success: bool = True) -> subprocess.CompletedProcess[str]: + cmd = [sys.executable, str(SCRIPT), action, "--artifact-dir", str(root / "assets"), + "--version", VERSION, "--tag", TAG, "--commit", COMMIT, + "--target", release.TARGET, *extra] + result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=60) + if success: + assert result.returncode == 0, result.stderr + else: + assert result.returncode != 0, result.stdout + return result + + +def bundle(name: str, sha: str, sbom: dict | None = None) -> dict: + statement = {"_type": "https://in-toto.io/Statement/v1", "subject": [{"name": name, "digest": {"sha256": sha}}], + "predicateType": "https://spdx.dev/Document/v2.3" if sbom is not None else "https://slsa.dev/provenance/v1", + "predicate": sbom if sbom is not None else {"buildDefinition": {"buildType": "fixture-only"}}} + return { + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "dsseEnvelope": {"payloadType": "application/vnd.in-toto+json", "payload": base64.b64encode(json.dumps(statement).encode()).decode(), "signatures": [{"sig": "fixture-only-not-a-valid-signature"}]}, + "verificationMaterial": {"certificate": "fixture-only"}, + } + + +class ReleaseReceiptTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="renderflow-release-tests-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / "source" + self.source.mkdir() + self.assets = self.root / "assets" + self.assets.mkdir() + (self.source / "schemas").mkdir() + for _, filename in release.SCHEMAS.values(): + shutil.copyfile(REPO / "schemas" / filename, self.source / "schemas" / filename) + shutil.copyfile(REPO / "Cargo.toml", self.source / "Cargo.toml") + shutil.copyfile(REPO / "Cargo.lock", self.source / "Cargo.lock") + self.assertEqual(release.tomllib.loads((self.source / "Cargo.toml").read_text())["workspace"]["package"]["version"], VERSION) + manifest = self.root / "mock" / "Cargo.toml" + manifest.parent.mkdir() + manifest.write_text("[package]\nname=\"anyhow\"\nversion=\"1.0.0\"\n") + (manifest.parent / "LICENSE-MIT").write_text("Synthetic MIT license text for parser test.\n") + lock = release.tomllib.loads((self.source / "Cargo.lock").read_text()) + anyhow = next(item for item in lock["package"] if item["name"] == "anyhow") + metadata = {"packages": [ + {"name": "renderflow-cli", "version": VERSION, "license": "MIT", "source": None, "manifest_path": str(self.source / "Cargo.toml")}, + {"name": "anyhow", "version": anyhow["version"], "license": "MIT OR Apache-2.0", "source": "registry+https://github.com/rust-lang/crates.io-index", "repository": "https://github.com/dtolnay/anyhow", "manifest_path": str(manifest)}, + ]} + self.metadata = self.root / "metadata.json" + self.metadata.write_text(json.dumps(metadata)) + self.name = f"renderflow-{release.TARGET}" + binary = self.assets / self.name + binary.write_text("#!/usr/bin/env sh\nif [ \"${1:-}\" = \"--version\" ]; then printf 'renderflow 0.3.0-rc.1\\n'; exit 0; fi\nexit 1\n") + binary.chmod(0o755) + + def prepared(self) -> dict: + call("prepare", self.root, "--source-dir", str(self.source), "--metadata-file", str(self.metadata)) + sha = release.digest(self.assets / self.name) + (self.assets / release.ATTESTATION).write_text(json.dumps(bundle(self.name, sha))) + sbom = json.loads((self.assets / release.SBOM).read_text()) + (self.assets / release.SBOM_ATTESTATION).write_text(json.dumps(bundle(self.name, sha, sbom))) + call("manifest", self.root, "--source-dir", str(self.source), + "--attestation-bundle", str(self.assets / release.ATTESTATION), + "--sbom-attestation-bundle", str(self.assets / release.SBOM_ATTESTATION)) + return json.loads((self.assets / release.MANIFEST).read_text()) + + def test_offline_receipt_is_self_consistent_and_portable(self) -> None: + receipt = self.prepared() + call("verify", self.root) + self.assertEqual(receipt["binary"]["sha256"], release.digest(self.assets / self.name)) + self.assertFalse(any(c["source_mutation"] for c in receipt["contracts"]["capabilities"])) + self.assertEqual(receipt["security"]["asset_signing"], "unsigned") + self.assertEqual(len(receipt["contracts"]["schemas"]), 7) + self.assertEqual(len(receipt["assets"]), 10) + self.assertTrue(all((self.assets / f"{entry['name']}.sha256").is_file() + for entry in receipt["assets"] if entry["kind"] != "checksum")) + self.assertIn("Synthetic MIT license text", (self.assets / release.NOTICES).read_text()) + inventory = json.loads((self.assets / release.SBOM).read_text()) + self.assertEqual(inventory["spdxVersion"], "SPDX-2.3") + self.assertTrue(any(p["name"] == "anyhow" for p in inventory["packages"])) + try: + import jsonschema + except ImportError: + pass + else: + schema = json.loads((REPO / "schemas" / "renderflow-release-manifest-v1.schema.json").read_text()) + jsonschema.validate(receipt, schema) + downloaded = self.root / "downloaded" + shutil.copytree(self.assets, downloaded) + renamed_root = self.root / "independent" + renamed_root.mkdir() + downloaded.rename(renamed_root / "assets") + call("verify", renamed_root) + + def test_binary_tamper_and_checksum_record_refused(self) -> None: + self.prepared() + (self.assets / self.name).write_bytes(b"tampered") + self.assertIn("asset digest/size mismatch", call("verify", self.root, success=False).stderr) + self.assertIn("checksum mismatch", call("manifest", self.root, "--source-dir", str(self.source), + "--attestation-bundle", str(self.assets / release.ATTESTATION), + "--sbom-attestation-bundle", str(self.assets / release.SBOM_ATTESTATION), success=False).stderr) + + def test_missing_bundle_wrong_subject_and_wrong_commit_refused(self) -> None: + self.prepared() + (self.assets / release.SBOM_ATTESTATION).unlink() + self.assertIn("missing or symlinked asset", call("verify", self.root, success=False).stderr) + wrong = bundle(self.name, "0" * 64, json.loads((self.assets / release.SBOM).read_text())) + (self.assets / release.SBOM_ATTESTATION).write_text(json.dumps(wrong)) + self.assertIn("asset digest/size mismatch", call("verify", self.root, success=False).stderr) + call("verify", self.root, "--commit", "b" * 40, success=False) + + def test_attestation_subject_refused_before_manifest(self) -> None: + call("prepare", self.root, "--source-dir", str(self.source), "--metadata-file", str(self.metadata)) + sha = release.digest(self.assets / self.name) + (self.assets / release.ATTESTATION).write_text(json.dumps(bundle(self.name, sha))) + (self.assets / release.SBOM_ATTESTATION).write_text(json.dumps(bundle(self.name, "0" * 64, json.loads((self.assets / release.SBOM).read_text())))) + result = call("manifest", self.root, "--source-dir", str(self.source), + "--attestation-bundle", str(self.assets / release.ATTESTATION), + "--sbom-attestation-bundle", str(self.assets / release.SBOM_ATTESTATION), success=False) + self.assertIn("attestation subject mismatch", result.stderr) + + def test_sbom_predicate_must_match_attached_inventory(self) -> None: + call("prepare", self.root, "--source-dir", str(self.source), "--metadata-file", str(self.metadata)) + sha = release.digest(self.assets / self.name) + (self.assets / release.ATTESTATION).write_text(json.dumps(bundle(self.name, sha))) + signed_other = {"spdxVersion": "SPDX-2.3", "packages": []} + (self.assets / release.SBOM_ATTESTATION).write_text(json.dumps(bundle(self.name, sha, signed_other))) + result = call("manifest", self.root, "--source-dir", str(self.source), + "--attestation-bundle", str(self.assets / release.ATTESTATION), + "--sbom-attestation-bundle", str(self.assets / release.SBOM_ATTESTATION), success=False) + self.assertIn("attached SBOM does not match signed predicate", result.stderr) + + def test_gh_verifier_is_bound_to_exact_commit_ref_workflow_and_predicate(self) -> None: + self.prepared() + fake_bin = self.root / "fake-tools" + fake_bin.mkdir() + gh = fake_bin / "gh" + gh.write_text("#!/usr/bin/env sh\nprintf '%s\\n' \"$*\" >> \"$GH_ARGV_LOG\"\n") + gh.chmod(0o755) + log = self.root / "gh-argv.txt" + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" + env["GH_ARGV_LOG"] = str(log) + command = [sys.executable, str(SCRIPT), "verify", "--artifact-dir", str(self.assets), + "--version", VERSION, "--tag", TAG, "--commit", COMMIT, + "--target", release.TARGET, "--verify-attestations"] + result = subprocess.run(command, env=env, capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + invocations = log.read_text().splitlines() + self.assertEqual(len(invocations), 2) + for invocation in invocations: + self.assertIn(f"--source-ref refs/tags/{TAG}", invocation) + self.assertIn(f"--source-digest {COMMIT}", invocation) + self.assertIn("--signer-workflow egohygiene/renderflow/.github/workflows/release.yml", invocation) + self.assertNotIn("--predicate-type", invocations[0]) + self.assertIn("--predicate-type https://spdx.dev/Document/v2.3", invocations[1]) + + @unittest.skipUnless(platform.system() == "Linux" and platform.machine() == "x86_64", "installer has one supported host") + def test_file_installer_exact_version_and_checksum(self) -> None: + self.prepared() + call("verify", self.root, "--installer-script", str(REPO / "scripts" / "install.sh")) + # A modified payload with the old checksum is rejected before installation. + (self.assets / self.name).write_bytes(b"tampered") + install = self.root / "install" + env = os.environ.copy() + env.update({"RENDERFLOW_VERSION": TAG, "RENDERFLOW_DOWNLOAD_BASE_URL": self.assets.as_uri(), "RENDERFLOW_INSTALL_DIR": str(install)}) + result = subprocess.run(["sh", str(REPO / "scripts" / "install.sh")], env=env, text=True, capture_output=True, check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("checksum verification failed", result.stderr) + self.assertFalse((install / "renderflow").exists()) + # A checksum-correct binary with a different version cannot overwrite an install. + (self.assets / self.name).write_text("#!/usr/bin/env sh\nprintf 'renderflow 9.9.9\\n'\n") + release.checksum(self.assets, self.name) + result = subprocess.run(["sh", str(REPO / "scripts" / "install.sh")], env=env, text=True, capture_output=True, check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("version differs", result.stderr) + self.assertFalse((install / "renderflow").exists()) + env["RENDERFLOW_VERSION"] = "latest" + result = subprocess.run(["sh", str(REPO / "scripts" / "install.sh")], env=env, text=True, capture_output=True, check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("exact release tag", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 025c3e3..27e020e 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,8 +1,10 @@ name: renderflow base: core22 +# Unpublished development template; no Snap Store channel has been verified +# for the v0.3.0-rc.1 integration candidate. version: git title: Renderflow -summary: Spec-driven document rendering engine +summary: Unpublished development template for Renderflow description: | Spec-driven document rendering engine for transforming Markdown into PDF, HTML, and DOCX output. @@ -15,7 +17,7 @@ contact: https://github.com/egohygiene/renderflow/issues issues: https://github.com/egohygiene/renderflow/issues source-code: https://github.com/egohygiene/renderflow website: https://github.com/egohygiene/renderflow -grade: stable +grade: devel confinement: classic apps: