Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/publish-crates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Publish crates

# Publishes the heph plugin SDK + its dependency closure to crates.io as a
# renamed `heph-*` chain (see scripts/publish/publish-crates.sh). Tag pushes
# publish for real; manual dispatch defaults to a dry run.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to publish (defaults to the tag / git-describe)."
required: false
type: string
dry_run:
description: "Run cargo publish --dry-run (no upload)."
required: false
type: boolean
default: true

concurrency:
group: publish-crates
cancel-in-progress: false

env:
SCCACHE_DIR: ${{ github.workspace }}/.sccache
SCCACHE_CACHE_SIZE: "2G"
CARGO_INCREMENTAL: "0"
# Use the runner's system Python for the manifest rewrite (stdlib only), so it
# works regardless of what the devenv shell provides.
PYTHON: /usr/bin/python3

jobs:
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup Nix + devenv
uses: ./.github/actions/setup-nix

- name: Compute version
id: version
run: |
if [ -n "${{ inputs.version }}" ]; then
VERSION="${{ inputs.version }}"
elif [ "${{ github.ref_type }}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="$(.github/workflows/version.sh "${{ github.run_number }}")"
VERSION="${VERSION#v}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "::notice title=Publish Version::$VERSION"

- name: Gen
shell: devenv shell bash -- -e {0}
run: gen

- name: Publish
shell: devenv shell bash -- -e {0}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
run: |
DRY=""
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
DRY="--dry-run"
fi
scripts/publish/publish-crates.sh "${{ steps.version.outputs.version }}" "$DRY"
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
name = "heph"
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0-only"
repository = "https://github.com/hephbuild/heph"

[workspace]
members = ["gen/proto", "crates/e2e", "crates/testkit", "crates/plugingo-e2e", "crates/htspec-derive", "crates/core", "crates/walk", "crates/proc", "crates/model", "crates/sandboxfuse", "crates/plugin", "crates/plugin-abi", "crates/plugin-stabby", "crates/plugin-sdk", "crates/plugin-go-cdylib", "crates/builtins", "crates/plugin-buildfile", "crates/driver-support", "crates/driver-bridge", "crates/plugin-exec", "crates/plugin-nix", "crates/plugin-query", "crates/plugin-go", "crates/telemetry", "crates/tui", "crates/lock", "crates/engine"]
Expand Down
661 changes: 661 additions & 0 deletions LICENSE.md

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion crates/plugin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ workspace = true
[dependencies]
hcore = { package = "core", path = "../core" }
hmodel = { package = "model", path = "../model" }
hwalk = { package = "walk", path = "../walk" }
htspec-derive = { path = "../htspec-derive" }
anyhow = "1.0.102"
serde = { version = "1", features = ["derive"] }
Expand Down
64 changes: 64 additions & 0 deletions scripts/publish/make_staging_root.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Emit a virtual workspace manifest for the publish staging tree.

The staging tree contains only the crates that get published to crates.io
(the plugin-sdk closure) plus the generated proto crate. The published crates
declare `[lints] workspace = true`, so the synthetic root must carry the same
`[workspace.lints.*]` block as the real repo root — we slice it verbatim out of
the original manifest rather than duplicating it here (so it never drifts).

Usage: make_staging_root.py <path-to-real-root-Cargo.toml> > staging/Cargo.toml
"""

import sys

# Member dirs in the staging tree (paths relative to the staging root).
MEMBERS = [
"crates/core",
"crates/model",
"crates/htspec-derive",
"crates/plugin",
"crates/driver-support",
"crates/plugin-abi",
"crates/plugin-stabby",
"crates/plugin-sdk",
"gen/proto",
]


def slice_lints(root_toml: str) -> str:
"""Return the contiguous `[workspace.lints...]` section block."""
lines = root_toml.splitlines()
start = None
for i, line in enumerate(lines):
if line.startswith("[workspace.lints"):
start = i
break
if start is None:
return ""
# Run until the next top-level table that is NOT a workspace.lints subtable.
end = len(lines)
for j in range(start + 1, len(lines)):
s = lines[j]
if s.startswith("[") and not s.startswith("[workspace.lints"):
end = j
break
return "\n".join(lines[start:end]).rstrip()


def main() -> None:
root = open(sys.argv[1], encoding="utf-8").read()
members = ",\n".join(f' "{m}"' for m in MEMBERS)
out = (
"# AUTOGENERATED by scripts/publish/make_staging_root.py — do not edit.\n"
"# Virtual workspace containing only the crates published to crates.io.\n\n"
"[workspace]\n"
'resolver = "2"\n'
f"members = [\n{members},\n]\n\n"
f"{slice_lints(root)}\n"
)
sys.stdout.write(out)


if __name__ == "__main__":
main()
93 changes: 93 additions & 0 deletions scripts/publish/publish-crates.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Publish the heph plugin SDK and its dependency closure to crates.io.
#
# crates.io cannot vendor path deps, so the closure ships as a renamed chain
# (`heph-*`). This script stages the publish set into an isolated virtual
# workspace, rewrites the manifests (rename + version + metadata), then publishes
# in dependency order. `cargo publish` blocks until each crate is in the index
# before returning, so the next crate's dependency requirement resolves.
#
# Prereqs: `gen` has run (gen/proto present); cargo + a crates.io token in
# CARGO_REGISTRY_TOKEN. Set PYTHON to pick the interpreter (defaults to python3).
#
# Usage: publish-crates.sh <version> [--dry-run]
set -euo pipefail

VERSION="${1:?usage: publish-crates.sh <version> [--dry-run]}"
DRYRUN="${2:-}"
PYTHON="${PYTHON:-python3}"

REPO="https://github.com/hephbuild/heph"
LICENSE="AGPL-3.0-only"

# Dependency (topological) publish order: deps before dependents.
ORDER=(
heph-core
heph-model
heph-htspec-derive
heph-proto-gen
heph-plugin
heph-driver-support
heph-plugin-abi
heph-plugin-stabby
heph-plugin-sdk
)

# Crate source dirs under crates/ to stage (proto-gen is staged from gen/proto).
CRATES=(core model htspec-derive plugin driver-support plugin-abi plugin-stabby plugin-sdk)

ROOT="$(git rev-parse --show-toplevel)"
SCRIPTS="$ROOT/scripts/publish"
cd "$ROOT"

if [[ ! -f gen/proto/Cargo.toml ]]; then
echo "error: gen/proto missing — run 'gen' before publishing." >&2
exit 1
fi

STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/crates" "$STAGE/gen"

echo "==> staging publish set into $STAGE"
for c in "${CRATES[@]}"; do
cp -R "crates/$c" "$STAGE/crates/$c"
done
cp -R gen/proto "$STAGE/gen/proto"

# Synthetic workspace root + manifest rewrites.
"$PYTHON" "$SCRIPTS/make_staging_root.py" Cargo.toml > "$STAGE/Cargo.toml"
"$PYTHON" "$SCRIPTS/rewrite_manifests.py" "$STAGE" "$VERSION" "$REPO" "$LICENSE"

# Ship the license inside every crate package.
for c in "${CRATES[@]}"; do
cp LICENSE.md "$STAGE/crates/$c/LICENSE.md"
done
cp LICENSE.md "$STAGE/gen/proto/LICENSE.md"

# A clean .git-less tree means `cargo package` includes the generated proto
# sources (otherwise gitignored). Fail loudly if the rewrite produced a bad
# manifest before we touch the registry.
echo "==> validating staged workspace"
( cd "$STAGE" && cargo metadata --no-deps --format-version 1 >/dev/null )

# A dry run can only ever exercise the leaf crates via `cargo publish
# --dry-run`: a dependent's `heph-*` deps aren't on the registry yet, so cargo's
# pre-upload resolution fails for them (the real publish works only because each
# crate reaches the index before the next one builds). So validate the whole
# renamed chain by compiling it from the path-linked staging workspace instead.
if [[ -n "$DRYRUN" ]]; then
echo "==> dry run: building the renamed chain (uploads skipped)"
( cd "$STAGE" && cargo build --workspace )
echo "==> dry run OK"
exit 0
fi

for crate in "${ORDER[@]}"; do
echo "==> publishing $crate@$VERSION"
# cargo publish blocks until the crate is in the index, so the next crate's
# dependency requirement resolves against the registry.
( cd "$STAGE" && cargo publish -p "$crate" --allow-dirty )
done

echo "==> done"
111 changes: 111 additions & 0 deletions scripts/publish/rewrite_manifests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Rewrite the staged crate manifests for a crates.io publish.

crates.io has no notion of "vendor a path dep" — every dependency must resolve
from the registry. So the publish set is shipped as a renamed chain: each crate
is published under a `heph-*` name (the bare names `core`/`model`/`plugin` are
taken/reserved), and every intra-set path dependency gains a `version` so cargo
can record a registry requirement while still resolving locally at verify time.

This operates ONLY on the isolated staging tree (see publish-crates.sh), which
contains exactly the publish set — so every relative path dep points at another
set member and the rewrite is uniform.

Usage: rewrite_manifests.py <staging-dir> <version> <repository-url> <license>
"""

import os
import re
import sys

# old crate name (== its dir under crates/, except proto-gen) -> published name.
RENAME = {
"core": "heph-core",
"model": "heph-model",
"htspec-derive": "heph-htspec-derive",
"proto-gen": "heph-proto-gen",
"plugin": "heph-plugin",
"driver-support": "heph-driver-support",
"plugin-abi": "heph-plugin-abi",
"plugin-stabby": "heph-plugin-stabby",
"plugin-sdk": "heph-plugin-sdk",
}

DESC = {
"heph-core": "Core primitives (content, hashing, async cancellation) for the heph build engine.",
"heph-model": "Address, matcher, and package model types for the heph build engine.",
"heph-htspec-derive": "Derive macros (Spec/SpecStruct/SpecEnum) for heph target-config specs.",
"heph-proto-gen": "Generated protobuf types for the heph plugin ABI.",
"heph-plugin": "Provider/Driver trait contract for heph plugins.",
"heph-driver-support": "Helper types for implementing heph drivers.",
"heph-plugin-abi": "Wire ABI types for out-of-process and cdylib heph plugins.",
"heph-plugin-stabby": "Stable-ABI cdylib transport for in-process heph plugins.",
"heph-plugin-sdk": "SDK for writing third-party heph plugins.",
}

# (manifest path relative to staging, old crate name).
MANIFESTS = [
("crates/core/Cargo.toml", "core"),
("crates/model/Cargo.toml", "model"),
("crates/htspec-derive/Cargo.toml", "htspec-derive"),
("crates/plugin/Cargo.toml", "plugin"),
("crates/driver-support/Cargo.toml", "driver-support"),
("crates/plugin-abi/Cargo.toml", "plugin-abi"),
("crates/plugin-stabby/Cargo.toml", "plugin-stabby"),
("crates/plugin-sdk/Cargo.toml", "plugin-sdk"),
("gen/proto/Cargo.toml", "proto-gen"),
]

# Deps keyed by the bare crate name (no `package = ` field) that nonetheless
# point at a renamed set member — they need an explicit `package = "heph-*"`
# injected so the published manifest names the registry crate, while the table
# key (the code-facing import name) stays put.
BARE_KEYS = {
"plugin-abi": "heph-plugin-abi",
"htspec-derive": "heph-htspec-derive",
}


def process(path: str, old: str, version: str, repo: str, license_: str) -> None:
new = RENAME[old]
s = open(path, encoding="utf-8").read()

# [package] name
s = re.sub(rf'(?m)^name = "{re.escape(old)}"$', f'name = "{new}"', s, count=1)

# [package] version -> publish version, and append the crates.io-required
# metadata directly after it.
meta = (
f'version = "{version}"\n'
f'license = "{license_}"\n'
f'description = "{DESC[new]}"\n'
f'repository = "{repo}"'
)
s = re.sub(r'(?m)^version = "[^"]*"$', meta, s, count=1)

# Rename every `package = "<old>"` dependency reference.
for o2, n2 in RENAME.items():
s = s.replace(f'package = "{o2}"', f'package = "{n2}"')

# Inject `package = ` for bare-key deps that resolve to a renamed crate.
for bare, target in BARE_KEYS.items():
s = re.sub(
rf'(?m)^{re.escape(bare)} = \{{ ',
f'{bare} = {{ package = "{target}", ',
s,
)

# Give every intra-set path dep an explicit version requirement.
s = re.sub(r'(path = "\.\.[^"]*")', rf'\1, version = "{version}"', s)

open(path, "w", encoding="utf-8").write(s)


def main() -> None:
stage, version, repo, license_ = sys.argv[1:5]
for rel, old in MANIFESTS:
process(os.path.join(stage, rel), old, version, repo, license_)


if __name__ == "__main__":
main()
Loading