name: Publish
on:
push:
tags:
- "v*"
permissions:
actions: read
contents: read
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: true
jobs:
preflight:
name: Await Required Checks
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Wait for CI, Tests, and Coverage
uses: actions/github-script@v7
with:
script: |
const REQUIRED_WORKFLOWS = ["CI", "Tests", "Coverage"];
const POLL_INTERVAL_MS = 20_000;
const TIMEOUT_MS = 30 * 60 * 1000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const deadline = Date.now() + TIMEOUT_MS;
const headSha = process.env.GITHUB_SHA;
const findLatestRunByWorkflow = (runs, workflowName) => {
const matchingRuns = runs.filter((run) => run.name === workflowName);
if (matchingRuns.length === 0) {
return null;
}
matchingRuns.sort((left, right) => {
return new Date(right.created_at) - new Date(left.created_at);
});
return matchingRuns[0];
};
while (Date.now() < deadline) {
const { data } = await github.rest.actions.listWorkflowRunsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
head_sha: headSha,
per_page: 100,
});
const runs = data.workflow_runs;
const summaries = [];
let allSucceeded = true;
let waiting = false;
let failedReason = null;
for (const workflowName of REQUIRED_WORKFLOWS) {
const run = findLatestRunByWorkflow(runs, workflowName);
if (!run) {
waiting = true;
allSucceeded = false;
summaries.push(`${workflowName}: missing`);
continue;
}
summaries.push(`${workflowName}: ${run.status}/${run.conclusion ?? "pending"}`);
if (run.status !== "completed") {
waiting = true;
allSucceeded = false;
continue;
}
if (run.conclusion !== "success") {
allSucceeded = false;
failedReason = `${workflowName} finished with conclusion ${run.conclusion}`;
break;
}
}
core.info(`Required workflow status for ${headSha}: ${summaries.join(" | ")}`);
if (failedReason) {
core.setFailed(failedReason);
return;
}
if (allSucceeded) {
core.info("All required workflows succeeded; publish can proceed.");
return;
}
if (waiting) {
await sleep(POLL_INTERVAL_MS);
continue;
}
}
core.setFailed(
"Timed out waiting for required workflows (CI, Tests, Coverage) to complete successfully"
);
validate-release:
name: Validate Release Policy
runs-on: ubuntu-latest
needs: preflight
outputs:
tag_name: ${{ steps.release_meta.outputs.tag_name }}
tag_version: ${{ steps.release_meta.outputs.tag_version }}
channel: ${{ steps.release_meta.outputs.channel }}
prerelease: ${{ steps.release_meta.outputs.prerelease }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate release channel policy
id: release_meta
shell: bash
run: |
set -euo pipefail
STABLE_TAG_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+$'
BETA_TAG_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'
ALPHA_TAG_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$'
RELEASE_BRANCH_REGEX='^origin/release/'
tag_name="${GITHUB_REF_NAME}"
tag_version="${tag_name#v}"
channel=""
if [[ "${tag_name}" =~ ${STABLE_TAG_REGEX} ]]; then
channel="stable"
prerelease="false"
elif [[ "${tag_name}" =~ ${BETA_TAG_REGEX} ]]; then
channel="beta"
prerelease="true"
elif [[ "${tag_name}" =~ ${ALPHA_TAG_REGEX} ]]; then
channel="alpha"
prerelease="true"
else
echo "Unsupported tag format: ${tag_name}"
echo "Allowed formats:"
echo " stable: vX.Y.Z"
echo " beta: vX.Y.Z-beta.N"
echo " alpha: vX.Y.Z-alpha.N"
exit 1
fi
manifest_version="$(awk -F' = ' '/^version = / {gsub(/"/, "", $2); print $2; exit}' Cargo.toml)"
if [[ -z "${manifest_version}" ]]; then
echo "Could not parse package version from Cargo.toml"
exit 1
fi
if [[ "${manifest_version}" != "${tag_version}" ]]; then
echo "Tag version (${tag_version}) does not match Cargo.toml version (${manifest_version})"
exit 1
fi
git fetch --prune --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
containing_branches="$(git branch -r --contains "${GITHUB_SHA}" | sed 's/^[* ]*//' | grep -v -- '->' || true)"
if [[ -z "${containing_branches}" ]]; then
echo "No remote branches contain commit ${GITHUB_SHA}"
exit 1
fi
require_branch() {
local branch_pattern="$1"
if grep -Eq "${branch_pattern}" <<<"${containing_branches}"; then
return 0
fi
echo "Tag ${tag_name} must point to a commit on branch pattern: ${branch_pattern}"
echo "Containing branches:"
echo "${containing_branches}"
exit 1
}
case "${channel}" in
stable)
if grep -Eq '^origin/main$' <<<"${containing_branches}"; then
echo "Validated stable release from origin/main"
elif grep -Eq "${RELEASE_BRANCH_REGEX}" <<<"${containing_branches}"; then
echo "Validated stable release from origin/release/*"
else
echo "Stable tags must point to commits on main or release/*"
echo "Containing branches:"
echo "${containing_branches}"
exit 1
fi
;;
beta)
require_branch '^origin/next$'
echo "Validated beta release from origin/next"
;;
alpha)
require_branch '^origin/canary$'
echo "Validated alpha release from origin/canary"
;;
esac
{
echo "tag_name=${tag_name}"
echo "tag_version=${tag_version}"
echo "channel=${channel}"
echo "prerelease=${prerelease}"
} >> "${GITHUB_OUTPUT}"
build-binaries:
name: Build Binaries (${{ matrix.target }})
runs-on: ${{ matrix.os }}
needs: validate-release
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: macos-14
target: x86_64-apple-darwin
- os: macos-14
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package archive (Unix)
if: runner.os != 'Windows'
shell: bash
env:
VERSION: ${{ needs.validate-release.outputs.tag_version }}
TARGET: ${{ matrix.target }}
run: |
set -euo pipefail
archive_name="gloves-${VERSION}-${TARGET}.tar.gz"
binary_path="target/${TARGET}/release/gloves"
test -f "${binary_path}"
stage_dir="stage/${TARGET}"
mkdir -p "${stage_dir}"
cp "${binary_path}" "${stage_dir}/gloves"
tar -C "${stage_dir}" -czf "${archive_name}" gloves
echo "ARCHIVE_PATH=${archive_name}" >> "${GITHUB_ENV}"
- name: Package archive (Windows)
if: runner.os == 'Windows'
shell: pwsh
env:
VERSION: ${{ needs.validate-release.outputs.tag_version }}
TARGET: ${{ matrix.target }}
run: |
$archiveName = "gloves-$env:VERSION-$env:TARGET.zip"
$binaryPath = "target/$env:TARGET/release/gloves.exe"
if (!(Test-Path $binaryPath)) {
throw "Missing binary at $binaryPath"
}
New-Item -ItemType Directory -Path "stage/$env:TARGET" -Force | Out-Null
Copy-Item $binaryPath "stage/$env:TARGET/gloves.exe"
Compress-Archive -Path "stage/$env:TARGET/gloves.exe" -DestinationPath $archiveName -Force
Add-Content -Path $env:GITHUB_ENV -Value "ARCHIVE_PATH=$archiveName"
- name: Upload packaged binary
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.target }}
path: ${{ env.ARCHIVE_PATH }}
if-no-files-found: error
release-assets:
name: Publish Release Assets
runs-on: ubuntu-latest
needs:
- validate-release
- build-binaries
permissions:
contents: write
steps:
- name: Download packaged binaries
uses: actions/download-artifact@v4
with:
pattern: release-*
merge-multiple: true
path: release-assets
- name: Generate SHA-256 checksums
shell: bash
run: |
set -euo pipefail
cd release-assets
sha256sum gloves-* > checksums.txt
- name: Create or update GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.validate-release.outputs.tag_name }}
prerelease: ${{ needs.validate-release.outputs.prerelease }}
generate_release_notes: true
files: |
release-assets/gloves-*
release-assets/checksums.txt
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
environment: crates-io
needs: validate-release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Publish crates in dependency order
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
set -euo pipefail
readonly CRATES_IO_API_BASE_URL="https://crates.io/api/v1/crates"
readonly CRATES_IO_USER_AGENT="gloves-release-workflow"
readonly INDEX_WAIT_ATTEMPTS=30
readonly INDEX_WAIT_SECONDS=10
crate_version_api_url() {
local crate_name="$1"
local crate_version="$2"
printf '%s/%s/%s' "${CRATES_IO_API_BASE_URL}" "${crate_name}" "${crate_version}"
}
crate_version_exists() {
local crate_name="$1"
local crate_version="$2"
curl --fail --silent --show-error \
--header "User-Agent: ${CRATES_IO_USER_AGENT}" \
--output /dev/null \
"$(crate_version_api_url "${crate_name}" "${crate_version}")"
}
publish_crate() {
local crate_name="$1"
local crate_version="$2"
if crate_version_exists "$crate_name" "$crate_version"; then
echo "Skipping ${crate_name} ${crate_version}; version already published on crates.io"
return 0
fi
if cargo publish -p "$crate_name" --locked; then
return 0
fi
if wait_for_index "$crate_name" "$crate_version"; then
echo "Skipping ${crate_name} ${crate_version}; version became visible on crates.io after publish attempt"
return 0
fi
echo "::error::Failed to publish ${crate_name}. Check CARGO_REGISTRY_TOKEN publish permissions for ${crate_name} on crates.io."
exit 1
}
wait_for_index() {
local crate_name="$1"
local crate_version="$2"
for attempt in $(seq 1 "${INDEX_WAIT_ATTEMPTS}"); do
if crate_version_exists "${crate_name}" "${crate_version}"; then
echo "Confirmed ${crate_name} ${crate_version} on crates.io"
return 0
fi
echo "Waiting for crates.io API to expose ${crate_name} ${crate_version} (attempt ${attempt}/${INDEX_WAIT_ATTEMPTS})"
sleep "${INDEX_WAIT_SECONDS}"
done
echo "Timed out waiting for ${crate_name} ${crate_version} to appear on crates.io"
return 1
}
version="${{ needs.validate-release.outputs.tag_version }}"
publish_crate "gloves-core" "$version"
wait_for_index "gloves-core" "$version"
publish_crate "gloves-config" "$version"
wait_for_index "gloves-config" "$version"
publish_crate "gloves" "$version"