From b67df75f9e5cdaf796fc23686da404cb23c1b89f Mon Sep 17 00:00:00 2001 From: Jo D Date: Thu, 21 May 2026 10:58:48 -0400 Subject: [PATCH 1/3] feat: add cargo publish action --- cargo-publish/action.yaml | 189 ++++++++++++++++++++++++++++++++++++++ readme.md | 68 ++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 cargo-publish/action.yaml diff --git a/cargo-publish/action.yaml b/cargo-publish/action.yaml new file mode 100644 index 0000000..79bcce0 --- /dev/null +++ b/cargo-publish/action.yaml @@ -0,0 +1,189 @@ +name: "Cargo Publish" +description: "Publishes one Rust crate to crates.io using Trusted Publishing" +inputs: + package: + description: "Package name to publish. Leave empty to infer from the current package." + required: false + default: "" + working-directory: + description: "Directory containing the Cargo manifest or workspace." + required: false + default: "." + toolchain: + description: "Rust toolchain to install and use." + required: false + default: "stable" + build-command: + description: "Build command to run before publishing. Leave empty to skip." + required: false + default: "cargo build" + locked: + description: "Pass --locked to cargo publish." + required: false + default: "true" + allow-dirty: + description: "Pass --allow-dirty to cargo publish." + required: false + default: "false" + dry-run: + description: "Run publish validation without publishing to crates.io." + required: false + default: "false" + check-version-available: + description: "Fail early when this crate version already exists on crates.io." + required: false + default: "true" + skip-existing: + description: "Skip publishing successfully when this crate version already exists on crates.io." + required: false + default: "false" +outputs: + package: + description: "Published package name." + value: ${{ steps.package.outputs.package }} + version: + description: "Published package version." + value: ${{ steps.package.outputs.version }} + published: + description: "Whether the action published to crates.io." + value: ${{ steps.status.outputs.published }} + already-published: + description: "Whether this crate version already existed on crates.io." + value: ${{ steps.version_exists.outputs.exists }} + +runs: + using: "composite" + steps: + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: ${{ inputs.toolchain }} + + - name: Show Cargo version + shell: bash + run: cargo --version + + - name: Run build command + if: inputs.build-command != '' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + ${{ inputs.build-command }} + + - name: Resolve package version + id: package + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + + PACKAGE_INPUT="${{ inputs.package }}" + if [ -n "$PACKAGE_INPUT" ]; then + if ! PKG_ID="$(cargo pkgid -p "$PACKAGE_INPUT" 2>&1)"; then + echo "$PKG_ID" + echo "::error::Could not resolve package '$PACKAGE_INPUT'." + exit 1 + fi + else + if ! PKG_ID="$(cargo pkgid 2>&1)"; then + echo "$PKG_ID" + echo "::error::Could not infer package. Set the package input when publishing from a workspace root." + exit 1 + fi + fi + + NAME_VERSION="${PKG_ID##*#}" + PACKAGE="${NAME_VERSION%@*}" + VERSION="${NAME_VERSION##*@}" + + if [ -z "$PACKAGE" ] || [ -z "$VERSION" ] || [ "$PACKAGE" = "$NAME_VERSION" ] || [ "$VERSION" = "$NAME_VERSION" ]; then + echo "::error::Could not parse package and version from cargo pkgid output: $PKG_ID" + exit 1 + fi + + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Publishing target: $PACKAGE@$VERSION" + + - name: Verify crate package + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + + PUBLISH_ARGS=(-p "${{ steps.package.outputs.package }}") + if [ "${{ inputs.locked }}" = "true" ]; then + PUBLISH_ARGS+=(--locked) + fi + if [ "${{ inputs.allow-dirty }}" = "true" ]; then + PUBLISH_ARGS+=(--allow-dirty) + fi + + cargo publish "${PUBLISH_ARGS[@]}" --dry-run + + - name: Check crates.io version availability + id: version_exists + shell: bash + run: | + set -euo pipefail + + if [ "${{ inputs.check-version-available }}" != "true" ] && [ "${{ inputs.skip-existing }}" != "true" ]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + REGISTRY_CHECK_DIR="${RUNNER_TEMP:-/tmp}" + PACKAGE="${{ steps.package.outputs.package }}" + VERSION="${{ steps.package.outputs.version }}" + + if (cd "$REGISTRY_CHECK_DIR" && cargo info "$PACKAGE@$VERSION" >/dev/null 2>&1); then + echo "exists=true" >> "$GITHUB_OUTPUT" + if [ "${{ inputs.skip-existing }}" = "true" ]; then + echo "$PACKAGE@$VERSION already exists on crates.io; skipping publish." + exit 0 + fi + + echo "::error::$PACKAGE@$VERSION already exists on crates.io. Bump the crate version before publishing." + exit 1 + fi + + echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Authenticate crates.io trusted publisher + if: inputs.dry-run != 'true' && steps.version_exists.outputs.exists != 'true' + id: crates_io_auth + uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4 + + - name: Publish to crates.io + if: inputs.dry-run != 'true' && steps.version_exists.outputs.exists != 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates_io_auth.outputs.token }} + run: | + set -euo pipefail + + PUBLISH_ARGS=(-p "${{ steps.package.outputs.package }}") + if [ "${{ inputs.locked }}" = "true" ]; then + PUBLISH_ARGS+=(--locked) + fi + if [ "${{ inputs.allow-dirty }}" = "true" ]; then + PUBLISH_ARGS+=(--allow-dirty) + fi + + cargo publish "${PUBLISH_ARGS[@]}" + + - name: Set publish status + id: status + shell: bash + run: | + set -euo pipefail + + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "published=false" >> "$GITHUB_OUTPUT" + elif [ "${{ steps.version_exists.outputs.exists }}" = "true" ]; then + echo "published=false" >> "$GITHUB_OUTPUT" + else + echo "published=true" >> "$GITHUB_OUTPUT" + fi diff --git a/readme.md b/readme.md index c9cc386..a7dc9d4 100644 --- a/readme.md +++ b/readme.md @@ -82,6 +82,74 @@ Customize the workflow to your needs! - `program`: Program name to build - `features`: Optional Cargo features to enable +### Cargo Publishing + +- `cargo-publish`: Publishes one Rust crate to crates.io using Trusted Publishing + - Uses GitHub OIDC and `rust-lang/crates-io-auth-action` + - Runs a build command and `cargo publish --dry-run` before publishing + - Optionally checks whether the crate version already exists on crates.io + - Leaves checkout, tests, generated clients, tags, and GitHub releases to the caller workflow + - Inputs: + - `package`: Package name to publish, or empty to infer from the current package + - `working-directory`: Directory containing the Cargo manifest or workspace + - `toolchain`: Rust toolchain to install and use + - `build-command`: Build command to run before publishing, or empty to skip + - `locked`: Pass `--locked` to `cargo publish` + - `allow-dirty`: Pass `--allow-dirty` to `cargo publish` + - `dry-run`: Validate without publishing to crates.io + - `check-version-available`: Fail early when this crate version already exists on crates.io + - `skip-existing`: Skip publishing successfully when this crate version already exists on crates.io + - Outputs: + - `package`: Published package name + - `version`: Published package version + - `published`: Whether the action published to crates.io + - `already-published`: Whether this crate version already existed on crates.io + +Caller workflows must grant OIDC token access and configure Trusted Publishing for the crate on crates.io. The Trusted Publisher configuration should match the caller repository and workflow file, not this shared action repository: + +```yaml +permissions: + contents: read + id-token: write +``` + +Single crate: + +```yaml +- uses: actions/checkout@v6 + +- uses: solana-developers/github-actions/cargo-publish@main + with: + package: solana-keychain + working-directory: rust +``` + +Workspace package: + +```yaml +- uses: actions/checkout@v6 + +- uses: solana-developers/github-actions/cargo-publish@main + with: + package: kora-lib + build-command: cargo build --workspace +``` + +Generated client: + +```yaml +- uses: actions/checkout@v6 + +- run: pnpm run generate-clients + +- uses: solana-developers/github-actions/cargo-publish@main + with: + package: subscriptions + working-directory: clients/rust + allow-dirty: "true" + skip-existing: "true" +``` + ### Deployment - `write-program-buffer`: Writes a buffer that will then later be set either from the provided keypair or from the squads multisig From e15ede4246abd98badb4701d34394865e382a2de Mon Sep 17 00:00:00 2001 From: Jo D Date: Fri, 22 May 2026 08:34:52 -0400 Subject: [PATCH 2/3] fix(cargo-publish): harden publish action --- cargo-publish/action.yaml | 62 +++++++++++++++++++++------------------ readme.md | 39 ++++++++++++++++-------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/cargo-publish/action.yaml b/cargo-publish/action.yaml index 79bcce0..5d0e309 100644 --- a/cargo-publish/action.yaml +++ b/cargo-publish/action.yaml @@ -13,10 +13,6 @@ inputs: description: "Rust toolchain to install and use." required: false default: "stable" - build-command: - description: "Build command to run before publishing. Leave empty to skip." - required: false - default: "cargo build" locked: description: "Pass --locked to cargo publish." required: false @@ -55,30 +51,27 @@ runs: using: "composite" steps: - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 - with: - toolchain: ${{ inputs.toolchain }} + shell: bash + env: + RUST_TOOLCHAIN: ${{ inputs.toolchain }} + run: | + set -euo pipefail + rustup toolchain install --profile minimal --no-self-update "$RUST_TOOLCHAIN" + rustup default "$RUST_TOOLCHAIN" - name: Show Cargo version shell: bash run: cargo --version - - name: Run build command - if: inputs.build-command != '' - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - set -euo pipefail - ${{ inputs.build-command }} - - name: Resolve package version id: package shell: bash working-directory: ${{ inputs.working-directory }} + env: + PACKAGE_INPUT: ${{ inputs.package }} run: | set -euo pipefail - PACKAGE_INPUT="${{ inputs.package }}" if [ -n "$PACKAGE_INPUT" ]; then if ! PKG_ID="$(cargo pkgid -p "$PACKAGE_INPUT" 2>&1)"; then echo "$PKG_ID" @@ -109,14 +102,18 @@ runs: - name: Verify crate package shell: bash working-directory: ${{ inputs.working-directory }} + env: + ALLOW_DIRTY: ${{ inputs.allow-dirty }} + LOCKED: ${{ inputs.locked }} + PACKAGE: ${{ steps.package.outputs.package }} run: | set -euo pipefail - PUBLISH_ARGS=(-p "${{ steps.package.outputs.package }}") - if [ "${{ inputs.locked }}" = "true" ]; then + PUBLISH_ARGS=(-p "$PACKAGE") + if [ "$LOCKED" = "true" ]; then PUBLISH_ARGS+=(--locked) fi - if [ "${{ inputs.allow-dirty }}" = "true" ]; then + if [ "$ALLOW_DIRTY" = "true" ]; then PUBLISH_ARGS+=(--allow-dirty) fi @@ -125,21 +122,24 @@ runs: - name: Check crates.io version availability id: version_exists shell: bash + env: + CHECK_VERSION_AVAILABLE: ${{ inputs.check-version-available }} + PACKAGE: ${{ steps.package.outputs.package }} + SKIP_EXISTING: ${{ inputs.skip-existing }} + VERSION: ${{ steps.package.outputs.version }} run: | set -euo pipefail - if [ "${{ inputs.check-version-available }}" != "true" ] && [ "${{ inputs.skip-existing }}" != "true" ]; then + if [ "$CHECK_VERSION_AVAILABLE" != "true" ] && [ "$SKIP_EXISTING" != "true" ]; then echo "exists=false" >> "$GITHUB_OUTPUT" exit 0 fi REGISTRY_CHECK_DIR="${RUNNER_TEMP:-/tmp}" - PACKAGE="${{ steps.package.outputs.package }}" - VERSION="${{ steps.package.outputs.version }}" if (cd "$REGISTRY_CHECK_DIR" && cargo info "$PACKAGE@$VERSION" >/dev/null 2>&1); then echo "exists=true" >> "$GITHUB_OUTPUT" - if [ "${{ inputs.skip-existing }}" = "true" ]; then + if [ "$SKIP_EXISTING" = "true" ]; then echo "$PACKAGE@$VERSION already exists on crates.io; skipping publish." exit 0 fi @@ -160,15 +160,18 @@ runs: shell: bash working-directory: ${{ inputs.working-directory }} env: + ALLOW_DIRTY: ${{ inputs.allow-dirty }} CARGO_REGISTRY_TOKEN: ${{ steps.crates_io_auth.outputs.token }} + LOCKED: ${{ inputs.locked }} + PACKAGE: ${{ steps.package.outputs.package }} run: | set -euo pipefail - PUBLISH_ARGS=(-p "${{ steps.package.outputs.package }}") - if [ "${{ inputs.locked }}" = "true" ]; then + PUBLISH_ARGS=(-p "$PACKAGE") + if [ "$LOCKED" = "true" ]; then PUBLISH_ARGS+=(--locked) fi - if [ "${{ inputs.allow-dirty }}" = "true" ]; then + if [ "$ALLOW_DIRTY" = "true" ]; then PUBLISH_ARGS+=(--allow-dirty) fi @@ -177,12 +180,15 @@ runs: - name: Set publish status id: status shell: bash + env: + DRY_RUN: ${{ inputs.dry-run }} + VERSION_EXISTS: ${{ steps.version_exists.outputs.exists }} run: | set -euo pipefail - if [ "${{ inputs.dry-run }}" = "true" ]; then + if [ "$DRY_RUN" = "true" ]; then echo "published=false" >> "$GITHUB_OUTPUT" - elif [ "${{ steps.version_exists.outputs.exists }}" = "true" ]; then + elif [ "$VERSION_EXISTS" = "true" ]; then echo "published=false" >> "$GITHUB_OUTPUT" else echo "published=true" >> "$GITHUB_OUTPUT" diff --git a/readme.md b/readme.md index a7dc9d4..1862fbe 100644 --- a/readme.md +++ b/readme.md @@ -86,16 +86,15 @@ Customize the workflow to your needs! - `cargo-publish`: Publishes one Rust crate to crates.io using Trusted Publishing - Uses GitHub OIDC and `rust-lang/crates-io-auth-action` - - Runs a build command and `cargo publish --dry-run` before publishing + - Runs `cargo publish --dry-run` before publishing - Optionally checks whether the crate version already exists on crates.io - Leaves checkout, tests, generated clients, tags, and GitHub releases to the caller workflow - Inputs: - `package`: Package name to publish, or empty to infer from the current package - `working-directory`: Directory containing the Cargo manifest or workspace - `toolchain`: Rust toolchain to install and use - - `build-command`: Build command to run before publishing, or empty to skip - `locked`: Pass `--locked` to `cargo publish` - - `allow-dirty`: Pass `--allow-dirty` to `cargo publish` + - `allow-dirty`: Pass `--allow-dirty` to `cargo publish` for generated files that are created during the workflow and are intentionally not checked into source control - `dry-run`: Validate without publishing to crates.io - `check-version-available`: Fail early when this crate version already exists on crates.io - `skip-existing`: Skip publishing successfully when this crate version already exists on crates.io @@ -113,15 +112,23 @@ permissions: id-token: write ``` +Pin this action to a released tag or commit SHA instead of `main`. + +Only set `allow-dirty: "true"` when a prior workflow step intentionally generated files that must be included in the crate but are not checked into source control. + Single crate: ```yaml - uses: actions/checkout@v6 -- uses: solana-developers/github-actions/cargo-publish@main +- name: Build package + working-directory: path/to/package + run: cargo build + +- uses: solana-developers/github-actions/cargo-publish@ with: - package: solana-keychain - working-directory: rust + package: my-crate + working-directory: path/to/package ``` Workspace package: @@ -129,10 +136,14 @@ Workspace package: ```yaml - uses: actions/checkout@v6 -- uses: solana-developers/github-actions/cargo-publish@main +- name: Build workspace + working-directory: path/to/workspace + run: cargo build --workspace + +- uses: solana-developers/github-actions/cargo-publish@ with: - package: kora-lib - build-command: cargo build --workspace + package: my-workspace-crate + working-directory: path/to/workspace ``` Generated client: @@ -142,10 +153,14 @@ Generated client: - run: pnpm run generate-clients -- uses: solana-developers/github-actions/cargo-publish@main +- name: Build generated client + working-directory: path/to/generated-crate + run: cargo build + +- uses: solana-developers/github-actions/cargo-publish@ with: - package: subscriptions - working-directory: clients/rust + package: my-generated-crate + working-directory: path/to/generated-crate allow-dirty: "true" skip-existing: "true" ``` From 2ed0e6d483f5e48e15b2893aac1ac5bfeaef26c6 Mon Sep 17 00:00:00 2001 From: Jo D Date: Fri, 22 May 2026 09:14:08 -0400 Subject: [PATCH 3/3] docs: pin squads release examples --- prepare-squads-release/action.yaml | 4 ++-- readme.md | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/prepare-squads-release/action.yaml b/prepare-squads-release/action.yaml index 8d35e0b..590eea0 100644 --- a/prepare-squads-release/action.yaml +++ b/prepare-squads-release/action.yaml @@ -81,7 +81,7 @@ runs: - name: Write program buffer id: write-program-buffer - uses: solana-developers/github-actions/write-program-buffer@main + uses: solana-developers/github-actions/write-program-buffer@e15ede4246abd98badb4701d34394865e382a2de with: program-id: ${{ inputs.program-id }} program: ${{ inputs.program }} @@ -97,7 +97,7 @@ runs: - name: Write metadata buffer id: write-metadata-buffer if: inputs.metadata-path != '' - uses: solana-developers/github-actions/write-metadata-buffer@main + uses: solana-developers/github-actions/write-metadata-buffer@e15ede4246abd98badb4701d34394865e382a2de with: idl-path: ${{ inputs.metadata-path }} rpc-url: ${{ inputs.rpc-url }} diff --git a/readme.md b/readme.md index 1862fbe..5571d5b 100644 --- a/readme.md +++ b/readme.md @@ -255,9 +255,11 @@ These actions use the [program-metadata](https://github.com/solana-program/progr For teams that do not want to add a CI-owned keypair as a Squads proposer, use `prepare-squads-release`. The keypair only pays for buffer preparation transactions. The resulting program buffer is owned by the Squads vault, and the upgrade proposal can be created manually in Squads. +Pin this action to a released tag or commit SHA instead of `main`. + ```yaml - name: Prepare Squads release buffers - uses: solana-developers/github-actions/prepare-squads-release@main + uses: solana-developers/github-actions/prepare-squads-release@ with: program: ${{ env.PROGRAM }} program-id: ${{ env.PROGRAM_ID }}