Skip to content
Merged
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
51 changes: 51 additions & 0 deletions .github/workflows/publish-crate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Prepare Rust crate

on:
workflow_dispatch:
inputs:
source_tag:
description: Published source tag
type: string
required: true
source_revision:
description: Exact published source revision
type: string
required: true

permissions:
contents: read

concurrency:
group: protocol-crate-publication
cancel-in-progress: false

jobs:
prepare:
if: github.repository == 'atrinik/protocol' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
CARGO_HOME: /tmp/atrinik-crate-prepare-cargo
CARGO_TARGET_DIR: /tmp/atrinik-crate-prepare-target
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- run: rustup toolchain install 1.97.1 --profile minimal
- id: prepare
env:
SOURCE_TAG: ${{ inputs.source_tag }}
SOURCE_REVISION: ${{ inputs.source_revision }}
run: |
set -euo pipefail
python3 tools/crate_publication.py prepare --source-tag "$SOURCE_TAG" \
--source-revision "$SOURCE_REVISION" --output "$RUNNER_TEMP/crate-prepared"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reviewed-crate-inputs
path: |
${{ runner.temp }}/crate-prepared/atrinik-protocol-0.2.0.crate
${{ runner.temp }}/crate-prepared/artifact.json
if-no-files-found: error
retention-days: 7
12 changes: 9 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
v2 schemas/adapters and GP1 1.1 access negotiation. Retained v1 schemas and
adapters are immutable baseline history, never a current runtime fallback.
- A newer Rust source version requires `policy/rust-crate-candidate.json`; it
stays unpublished and registry publication disabled until a separate reviewed
release policy. Local candidate packaging cannot rewrite the published digest.
stays unpublished and registry upload disabled until a separate reviewed
release policy. Prepared source may allow only crates.io in Cargo; the manual
preparation workflow must remain credential-free with no OIDC or upload job.
Actual source-release artifact pins are recorded only after reproducible
preparation, never predicted from a future merge or substituted from HEAD. Local candidate packaging cannot rewrite the published digest.
- GP1 is not the numeric C/Python registry in `atrinik/classic/protocol`. Do
not add classic IDs, MAP2/ADS compatibility, C/Python bindings, or a dual
protocol path here.
Expand Down Expand Up @@ -99,7 +102,10 @@
short-lived token only to the upload step; and verify the public checksum.
Never publish from a pull request, moving branch, dirty tree, unreviewed
artifact, long-lived registry secret, or automatic semantic-release side
effect. Keep publication disabled until a separate policy activation review.
effect. Keep registry upload disabled until a separate policy activation review.
The current preparation-only workflow cannot request credentials or upload.
Preserve a flat downloadable release inventory and a deterministic nested
contract bundle, each with complete checksum coverage.
- Run the aggregate contract now present:

```sh
Expand Down
59 changes: 53 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ and evaluates resolved Cargo license expressions against the SPDX policy.

The aggregate required check is `Protocol validation`. Release tags create a
source/bindings/schema archive, descriptor, fixtures, checksums, CycloneDX
SBOM, build provenance, notices, and MIT license. A Rust crate version has
SBOM, build provenance, notices, and MIT license. Every release asset is a flat
file covered by the outer `SHA256SUMS`. The deterministic
`atrinik-protocol-contracts-VERSION.tar.gz` additionally contains the complete
contract layout, both nested directory fixture trees, and its own checksum
manifest. Extract that bundle to validate nested fixtures; the GitHub asset
glob cannot upload directories. A Rust crate version has
exactly one policy-owned repository release, revision, asset name, and digest
in `policy/rust-crate-release.json`. Only that owning release may include the
self-contained registry-ready `.crate`; later repository releases omit it
Expand All @@ -107,9 +112,12 @@ Git revision.

## Rust registry publication

The unpublished 0.2.0 candidate sets `package.publish = false`; both Cargo and
the policy checker reject publication. Enabling publication requires a separate
reviewed source and policy activation, with a new immutable package checksum.
The unpublished 0.2.0 candidate is prepared for a future registry release. Its
manifest allows only crates.io so the next reviewed source release can produce
its final publishable bytes. This is not an upload authorization: the manual
workflow has no OIDC permission, registry token, or upload command.
`policy/rust-crate-next.json` contains no invented revision or checksum; it
remains `awaiting-source-release` until actual artifacts receive separate review.

Crate `atrinik-protocol` version `0.1.0` is registered on crates.io with
SHA-256
Expand All @@ -127,7 +135,7 @@ policy digest. It is retained as immutable release history; ordinary future
repository releases omit crate `0.1.0` rather than regenerating it.

`policy/rust-crate-publishing.json` records the registered coordinates and
keeps future publication `disabled-until-reviewed-activation`. Publishing is
keeps future publication `prepared-awaiting-reviewed-artifact`. Publishing is
permanent and is never implied by merging ordinary protocol changes or by the
semantic-release workflow.

Expand All @@ -149,10 +157,49 @@ before proposing contract material. The cross-repository roadmap is
## Unpublished access-token crate candidate

`policy/rust-crate-candidate.json` records source version 0.2.0 as explicitly
unpublished with publication disabled. Aggregate validation packages and builds
unpublished with registry upload disabled. Aggregate validation packages and builds
that candidate locally; source releases omit a registry crate until a separately
reviewed immutable release policy assigns its version, revision and digest.
The existing published 0.1.0 release policy and checksum are unchanged. A temporary
task-owned dependency override may validate coordinated consumers, but consumers
must pin an actual immutable release before readiness; no permanent sibling path
or fabricated release is accepted.


## Preparing the next Rust crate

The manual `publish-crate.yml` workflow currently prepares only. Run it on main
with an actual published source tag and its full revision after separately
approving that dispatch. It checks local and public tag identity, release
provenance, source ancestry and cleanliness, packages twice with Rust 1.97.1,
checks package inventory/VCS metadata/registry-only dependencies, and emits the
actual `.crate` and `artifact.json`. It never requests a registry token or
uploads to crates.io. Release v2.6.0 has `publish = false` and is deliberately
rejected: changing its manifest would create different, unreviewed bytes.

After a separately approved source merge and release, use the resulting exact
artifact evidence in a small activation PR. That PR must replace the pending
artifact policy with the real repository release, source revision, asset name
and SHA-256; retain the immutable published 0.1.0 record; and add the reviewed
Trusted Publishing upload job. Do not package activation HEAD: reproduce the
pinned prepared-source release. Attach the exact reviewed crate to its owning
release only with explicit authorization, then verify the public asset digest.
GitHub currently reports v2.6.0 as `immutable: false`; a pinned content digest
and repeated tag/asset checks detect drift but do not enable release immutability.

The activation must bind crates.io Trusted Publishing to `atrinik/protocol`,
`publish-crate.yml`, and `crates-io-release`. The proposed GitHub environment is
main-only with owner review and no secrets or variables; setup is a separate
owner action. Grant `id-token: write` only to the future upload job. Reproduce
and verify the reviewed crate before token exchange, including again after
environment review. Pass the short-lived token only to `cargo publish --locked
--no-verify`; retain the action's token revocation. Recheck both the public API
and sparse-index checksum after upload. Matching existing bytes are idempotent
success, different bytes are terminal, and an indeterminate result requires
public-state inspection before retry. No long-lived registry token is allowed.

The credential-free verifier already tests these artifact and registry checks,
but adding pins or upload capability still requires a separate policy review.
The current policy checker rejects that activation until reviewed code changes
make it explicit. See the [official token action](https://github.com/rust-lang/crates-io-auth-action)
and [Cargo publication contract](https://doc.rust-lang.org/cargo/commands/cargo-publish.html).
2 changes: 1 addition & 1 deletion crates/atrinik-protocol/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ readme = "README.md"
repository.workspace = true
rust-version.workspace = true
version.workspace = true
publish = false
publish = ["crates-io"]

[dependencies]
bytes.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion policy/rust-crate-candidate.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
"version": "0.2.0",
"base_published_version": "0.1.0",
"status": "unpublished",
"publication": "disabled"
"publication": "prepared-without-upload"
}
7 changes: 7 additions & 0 deletions policy/rust-crate-next.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"name": "atrinik-protocol",
"version": "0.2.0",
"status": "awaiting-source-release",
"artifact": null
}
2 changes: 1 addition & 1 deletion policy/rust-crate-publishing.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"sha256": "413c4da6c1b304d4a622065efe0d36c3f591041972f1a5ee76c538926f3c0b6b"
},
"future": {
"status": "disabled-until-reviewed-activation",
"status": "prepared-awaiting-reviewed-artifact",
"authentication": "trusted-publishing",
"repository_owner": "atrinik",
"repository": "protocol",
Expand Down
26 changes: 18 additions & 8 deletions tools/check-crate-release-policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
WORKFLOWS = ROOT / ".github" / "workflows"
PUBLISH_WORKFLOW = WORKFLOWS / "publish-crate.yml"
BOOTSTRAP_CHECK = ROOT / "tools" / "check-crate-publication.py"
EXPECTED_WORKFLOWS = {"pr-title.yml", "release.yml", "validate.yml"}
EXPECTED_WORKFLOWS = {"pr-title.yml", "release.yml", "validate.yml", "publish-crate.yml"}

EXPECTED_FUTURE_POLICY = {
"status": "disabled-until-reviewed-activation",
"status": "prepared-awaiting-reviewed-artifact",
"authentication": "trusted-publishing",
"repository_owner": "atrinik",
"repository": "protocol",
Expand Down Expand Up @@ -50,13 +50,13 @@ def main() -> None:
if not candidate_path.is_file():
raise SystemExit("unpublished crate requires explicit candidate policy")
manifest = tomllib.loads((ROOT / "crates/atrinik-protocol/Cargo.toml").read_text())
if manifest["package"].get("publish") is not False:
raise SystemExit("unpublished crate manifest must set publish = false")
if manifest["package"].get("publish") != ["crates-io"]:
raise SystemExit("prepared crate manifest must restrict publish to crates-io")
candidate = load_json(candidate_path)
expected_candidate = {
"schema_version": 1, "name": release["name"],
"version": workspace_version, "base_published_version": release["version"],
"status": "unpublished", "publication": "disabled",
"status": "unpublished", "publication": "prepared-without-upload",
}
if candidate != expected_candidate or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", workspace_version):
raise SystemExit("unpublished crate candidate policy changed")
Expand Down Expand Up @@ -85,8 +85,18 @@ def main() -> None:
if publishing != expected:
raise SystemExit("reviewed Rust registry policy changed")

if PUBLISH_WORKFLOW.exists():
raise SystemExit("Rust registry publication is not activated")
next_policy = load_json(ROOT / "policy/rust-crate-next.json")
if next_policy != {"schema_version": 1, "name": release["name"],
"version": workspace_version, "status": "awaiting-source-release",
"artifact": None}:
raise SystemExit("actual artifact pins require a separate activation review")
if not PUBLISH_WORKFLOW.is_file():
raise SystemExit("credential-free preparation workflow is required")
preparation = PUBLISH_WORKFLOW.read_text()
for forbidden in ("id-token:", "contents: write", "push:", "pull_request:",
"workflow_call:", "secrets.", "environment:"):
if forbidden in preparation:
raise SystemExit("preparation workflow must remain credential-free and manual")
if BOOTSTRAP_CHECK.exists():
raise SystemExit("one-time bootstrap checker must remain removed")

Expand All @@ -111,7 +121,7 @@ def main() -> None:
required_readme = (
"The one-use bootstrap workflow",
"has been removed",
"disabled-until-reviewed-activation",
"prepared-awaiting-reviewed-artifact",
"crates-io-release",
"Trusted Publishing",
)
Expand Down
Loading
Loading