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
106 changes: 92 additions & 14 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: Existing signed release tag to recover
required: true
type: string

env:
RUST_BACKTRACE: 1
Expand All @@ -14,7 +20,7 @@ permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
cancel-in-progress: false

jobs:
Expand All @@ -26,6 +32,8 @@ jobs:
actions: read
contents: read
outputs:
release_commit: ${{ steps.identity.outputs.release_commit }}
release_tag: ${{ steps.identity.outputs.release_tag }}
crate_name: ${{ steps.preflight.outputs.crate_name }}
crate_sha256: ${{ steps.preflight.outputs.crate_sha256 }}
crate_version: ${{ steps.preflight.outputs.crate_version }}
Expand All @@ -44,12 +52,54 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}

- name: Resolve release identity
id: identity
env:
EVENT_NAME: ${{ github.event_name }}
REQUESTED_TAG: ${{ inputs.tag }}
WORKFLOW_COMMIT: ${{ github.sha }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "release recovery must run from main, not $GITHUB_REF" >&2
exit 1
fi
release_tag="$REQUESTED_TAG"
else
release_tag="$GITHUB_REF_NAME"
fi
if [[ ! "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
echo "release tag must be valid semver prefixed with v: $release_tag" >&2
exit 1
fi
if ! git rev-parse -q --verify "${release_tag}^{tag}" >/dev/null; then
echo "release ref must be an annotated tag: $release_tag" >&2
exit 1
fi
release_commit="$(git rev-parse "${release_tag}^{commit}")"
checkout_commit="$(git rev-parse HEAD)"
if [[ "$checkout_commit" != "$release_commit" ]]; then
echo "checked-out commit $checkout_commit does not match $release_tag commit $release_commit" >&2
exit 1
fi
if [[ "$EVENT_NAME" == "push" && "$WORKFLOW_COMMIT" != "$release_commit" ]]; then
echo "tag push commit $WORKFLOW_COMMIT does not match $release_tag commit $release_commit" >&2
exit 1
fi
{
echo "release_commit=$release_commit"
echo "release_tag=$release_tag"
} >> "$GITHUB_OUTPUT"

- name: Verify CT and RSA release evidence
id: evidence
env:
GH_TOKEN: ${{ github.token }}
run: scripts/ci/release-evidence-check.sh --commit "$GITHUB_SHA"
RELEASE_COMMIT: ${{ steps.identity.outputs.release_commit }}
run: scripts/ci/release-evidence-check.sh --commit "$RELEASE_COMMIT"

- name: Setup
uses: ./.github/actions/setup
Expand All @@ -60,14 +110,33 @@ jobs:
enable-magic-cache: false
enable-rust-cache: true

- name: Checkout reviewed recovery tooling
if: github.event_name == 'workflow_dispatch'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
path: target/release-automation
persist-credentials: false
ref: ${{ github.sha }}

- name: Install recovery SemVer checker
if: github.event_name == 'workflow_dispatch'
run: |
set -euo pipefail
automation_root=target/release-automation
"$automation_root/scripts/ci/install-tools.sh" semver
rm -rf "$automation_root"

- name: Release preflight
id: preflight
run: scripts/ci/release-preflight.sh --crate rscrypto --tag "$GITHUB_REF_NAME"
env:
RELEASE_TAG: ${{ steps.identity.outputs.release_tag }}
run: scripts/ci/release-preflight.sh --crate rscrypto --tag "$RELEASE_TAG"

- name: Preserve validated release inputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-inputs-${{ github.sha }}
name: release-inputs-${{ steps.identity.outputs.release_commit }}
path: |
${{ steps.preflight.outputs.crate_path }}
${{ steps.preflight.outputs.source_path }}
Expand All @@ -93,6 +162,7 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ needs.preflight.outputs.release_tag }}

- name: Setup
uses: ./.github/actions/setup
Expand All @@ -106,7 +176,7 @@ jobs:
- name: Restore validated release inputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-inputs-${{ github.sha }}
name: release-inputs-${{ needs.preflight.outputs.release_commit }}
path: target/package

- name: Verify validated release inputs
Expand Down Expand Up @@ -160,9 +230,10 @@ jobs:
env:
CRATE_VERSION: ${{ steps.package.outputs.crate_version }}
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.preflight.outputs.release_commit }}
run: |
scripts/ci/repository-controls-evidence.sh \
--commit "$GITHUB_SHA" \
--commit "$RELEASE_COMMIT" \
--output "target/repository-controls/rscrypto-${CRATE_VERSION}-repository-controls.json" \
--allow-redacted-bypass

Expand Down Expand Up @@ -193,10 +264,11 @@ jobs:
CRATE_VERSION: ${{ steps.package.outputs.crate_version }}
EVIDENCE_COMMIT: ${{ needs.preflight.outputs.weekly_commit }}
EVIDENCE_VERSION: ${{ needs.preflight.outputs.weekly_version }}
RELEASE_COMMIT: ${{ needs.preflight.outputs.release_commit }}
run: |
scripts/ci/package-release-ct-evidence.sh \
--version "$CRATE_VERSION" \
--commit "$GITHUB_SHA" \
--commit "$RELEASE_COMMIT" \
--evidence-version "$EVIDENCE_VERSION" \
--evidence-commit "$EVIDENCE_COMMIT"

Expand All @@ -215,11 +287,13 @@ jobs:
REPOSITORY_CONTROLS_PATH: ${{ steps.repository_controls.outputs.evidence_path }}
EVIDENCE_COMMIT: ${{ needs.preflight.outputs.weekly_commit }}
EVIDENCE_MODE: ${{ needs.preflight.outputs.weekly_evidence_mode }}
RELEASE_COMMIT: ${{ needs.preflight.outputs.release_commit }}
RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }}
run: |
scripts/ci/write-release-manifest.sh \
--version "$CRATE_VERSION" \
--tag "$GITHUB_REF_NAME" \
--commit "$GITHUB_SHA" \
--tag "$RELEASE_TAG" \
--commit "$RELEASE_COMMIT" \
--source "$SOURCE_PATH" \
--crate "$CRATE_PATH" \
--ct-evidence "$CT_EVIDENCE_PATH" \
Expand Down Expand Up @@ -303,14 +377,18 @@ jobs:
RELEASE_MANIFEST_NAME: ${{ steps.release_manifest.outputs.manifest_name }}
RELEASE_MANIFEST_PATH: ${{ steps.release_manifest.outputs.manifest_path }}
RELEASE_MANIFEST_SHA256: ${{ steps.release_manifest.outputs.manifest_sha256 }}
RELEASE_COMMIT: ${{ needs.preflight.outputs.release_commit }}
RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }}
WORKFLOW_COMMIT: ${{ github.sha }}
run: |
set -euo pipefail
notes_file="$(mktemp)"
cat > "$notes_file" <<EOF
Release tag: \`${GITHUB_REF_NAME}\`
Release commit: \`${GITHUB_SHA}\`
Release tag: \`${RELEASE_TAG}\`
Release commit: \`${RELEASE_COMMIT}\`
Workflow commit: \`${WORKFLOW_COMMIT}\`

See [CHANGELOG.md](https://github.com/loadingalias/rscrypto/blob/${GITHUB_SHA}/CHANGELOG.md) for changes.
See [CHANGELOG.md](https://github.com/loadingalias/rscrypto/blob/${RELEASE_COMMIT}/CHANGELOG.md) for changes.

Crate artifact: \`${CRATE_NAME}\`
SHA-256: \`${CRATE_SHA256}\`
Expand Down Expand Up @@ -343,8 +421,8 @@ jobs:
EOF

scripts/ci/publish-immutable-release.sh \
--tag "$GITHUB_REF_NAME" \
--title "rscrypto $GITHUB_REF_NAME" \
--tag "$RELEASE_TAG" \
--title "rscrypto $RELEASE_TAG" \
--notes "$notes_file" \
--asset "$CRATE_PATH" \
--asset "$SOURCE_PATH" \
Expand Down
37 changes: 28 additions & 9 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,37 @@ receive anything, the workflow:
crates.io token through OIDC, publishes the same crate, then downloads it
from crates.io and verifies its SHA-256.

Any change after the evidence run—including a version-only or release-tooling
change—creates a new release candidate and requires fresh paired Weekly and
RISC-V evidence. Ancestor binaries are never promoted into an exact-commit
constant-time claim.
Any change to the candidate after the evidence run—including a version-only,
dependency, build-input, or test-policy change—creates a new release candidate
and requires fresh paired Weekly and RISC-V evidence. Ancestor binaries are
never promoted into an exact-commit constant-time claim.

## Recovery

After a partial failure, rerun the workflow on the same tag and commit. If
crates.io already contains the version, the workflow downloads it and compares
its SHA-256 before touching the GitHub Release. The workflow can repair and
publish a draft release. It never overwrites a published immutable release; it
verifies the release attestation and stable crate and source assets before
Rerun a transient or partial failure on the same tag and commit:

```bash
gh run rerun RUN_ID --failed
```

If the committed workflow or one of its pinned tools cannot complete, merge the
smallest repair through the required `Complete` check, then dispatch the
reviewed recovery path from `main`:

```bash
gh workflow run release.yaml --ref main -f tag=vX.Y.Z
```

Recovery checks out the existing annotated tag, verifies its allowed signature,
and binds the package, evidence, release manifest, and release notes to the
tag's commit rather than the newer workflow commit. Confirm that Preflight
reports the intended tag commit before approving the `crates-io` environment.
The recovery path cannot run from an unprotected branch.

If crates.io already contains the version, the workflow downloads it and
compares its SHA-256 before touching the GitHub Release. The workflow can repair
and publish a draft release. It never overwrites a published immutable release;
it verifies the release attestation and stable crate and source assets before
publishing to crates.io. Any mismatch stops the release.

If the signed-tag key changes, update `.github/allowed-signers` in a reviewed
Expand Down
19 changes: 19 additions & 0 deletions scripts/ci/check-ci-ownership-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ baseline="$TMP_ROOT/baseline"
make_fixture "$baseline"
"$CHECKER" --root "$baseline" >/dev/null

missing_recovery_tag="$TMP_ROOT/missing-recovery-tag"
make_fixture "$missing_recovery_tag"
yq eval 'del(.on.workflow_dispatch.inputs.tag)' -i \
"$missing_recovery_tag/.github/workflows/release.yaml"
expect_failure "$missing_recovery_tag" "release recovery has no explicit tag identity"

unprotected_recovery="$TMP_ROOT/unprotected-recovery"
make_fixture "$unprotected_recovery"
sed -i.bak 's#refs/heads/main#refs/heads/recovery#' \
"$unprotected_recovery/.github/workflows/release.yaml"
rm -f "$unprotected_recovery/.github/workflows/release.yaml.bak"
expect_failure "$unprotected_recovery" "release recovery accepts unprotected workflow code"

mutable_publish_checkout="$TMP_ROOT/mutable-publish-checkout"
make_fixture "$mutable_publish_checkout"
yq eval '(.jobs.publish.steps[] | select(.name == "Checkout") | .with.ref) = "${{ github.ref }}"' -i \
"$mutable_publish_checkout/.github/workflows/release.yaml"
expect_failure "$mutable_publish_checkout" "release publication ignores the preflight-verified tag"

hosted_macos="$TMP_ROOT/hosted-macos"
make_fixture "$hosted_macos"
yq eval '.jobs.hosted_macos = {"runs-on": "macos-15", "steps": [{"run": "true"}]}' -i \
Expand Down
20 changes: 19 additions & 1 deletion scripts/ci/check-ci-ownership.sh
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,24 @@ grep -Eq 'HOST_ARGS\+=\(--feature-matrix\)' "$CHECK_ALL" \
|| fail "ordinary workflows must have exactly one executable feature-matrix owner"
[[ $(count_matches 'just check-feature-matrix' "$WORKFLOWS" "$RUN_RUST_JOB") -eq 1 ]] \
|| fail "ordinary workflows must have exactly one compile feature-matrix owner"
[[ $(yq eval '.on.workflow_dispatch.inputs.tag.required' "$RELEASE") == "true" ]] \
|| fail "release recovery must require an explicit existing tag"
[[ $(yq eval '.on.workflow_dispatch.inputs.tag.type' "$RELEASE") == "string" ]] \
|| fail "release recovery tag input must be a string"
# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts.
[[ $(yq eval '.jobs.preflight.steps[] | select(.name == "Checkout") | .with.ref' "$RELEASE") \
== '${{ github.event_name == '\''workflow_dispatch'\'' && inputs.tag || github.ref }}' ]] \
|| fail "release preflight must check out the requested recovery tag"
# shellcheck disable=SC2016 # GitHub expressions are intentional literal workflow contracts.
[[ $(yq eval '.jobs.publish.steps[] | select(.name == "Checkout") | .with.ref' "$RELEASE") \
== '${{ needs.preflight.outputs.release_tag }}' ]] \
|| fail "release publication must check out the preflight-verified tag"
identity_step=$(yq eval '.jobs.preflight.steps[] | select(.id == "identity") | .run' "$RELEASE")
grep -Fq 'refs/heads/main' <<<"$identity_step" \
|| fail "release recovery must reject workflow code outside protected main"
recovery_tool_step=$(yq eval '.jobs.preflight.steps[] | select(.name == "Install recovery SemVer checker") | .run' "$RELEASE")
grep -Fq 'install-tools.sh" semver' <<<"$recovery_tool_step" \
|| fail "release recovery must use the authenticated SemVer tool installer"
# shellcheck disable=SC2016 # `$crate` is an intentional literal in the release-preflight contract regex.
[[ $(count_matches 'cargo semver-checks --package "\$crate" --all-features' "$RELEASE_PREFLIGHT") -eq 1 ]] \
|| fail "tag preflight must have exactly one final-version SemVer owner"
Expand Down Expand Up @@ -530,7 +548,7 @@ grep -Fq 'ct-raw-' "$RELEASE_EVIDENCE" \
|| fail "reusable RSA workflow concurrency must not collide with its caller"
grep -Fq 'name: ct-raw-rise-riscv' "$RELEASE" \
|| fail "release must download the explicitly raw RISC-V CT artifact"
grep -Fq 'scripts/ci/release-evidence-check.sh --commit "$GITHUB_SHA"' "$RELEASE" \
grep -Fq 'scripts/ci/release-evidence-check.sh --commit "$RELEASE_COMMIT"' "$RELEASE" \
|| fail "release must require paired Weekly and RISC-V evidence from one valid commit"
grep -Fq 'scripts/ci/repository-controls-evidence.sh' "$RELEASE" \
|| fail "release must capture the live repository controls"
Expand Down
7 changes: 5 additions & 2 deletions scripts/ci/install-tools.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Install CI tools through authenticated package-manager boundaries.
# Usage: install-tools.sh [standard|quality|release|rail|ci|supply-chain|bench|ibm|fuzz|coverage|ct-linux|minimal|none]
# Usage: install-tools.sh [standard|quality|release|semver|rail|ci|supply-chain|bench|ibm|fuzz|coverage|ct-linux|minimal|none]

set -euo pipefail

Expand All @@ -10,7 +10,7 @@ CARGO_NEXTEST_VERSION=0.9.140
CARGO_DENY_VERSION=0.20.2
CARGO_AUDIT_VERSION=0.22.2
CARGO_RAIL_VERSION=0.20.0
CARGO_SEMVER_CHECKS_VERSION=0.48.0
CARGO_SEMVER_CHECKS_VERSION=0.50.0
JUST_VERSION=1.57.0
ZIZMOR_VERSION=1.26.1
CARGO_CRITERION_VERSION=1.1.0
Expand Down Expand Up @@ -307,6 +307,9 @@ case "$MODE" in
install_cargo_tool cargo-deny "$CARGO_DENY_VERSION"
install_cargo_tool cargo-audit "$CARGO_AUDIT_VERSION"
;;
semver)
install_cargo_tool cargo-semver-checks "$CARGO_SEMVER_CHECKS_VERSION"
;;
rail)
install_cargo_tool cargo-rail "$CARGO_RAIL_VERSION"
;;
Expand Down
4 changes: 2 additions & 2 deletions scripts/ci/tool-integrity-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ package_temp="$TMP_ROOT/package-temp"
mkdir -p "$package_home/.cargo/bin"
mkdir -p "$package_temp"
: >"$package_log"
for mode in standard quality release rail ci supply-chain ibm bench fuzz coverage minimal none; do
for mode in standard quality release semver rail ci supply-chain ibm bench fuzz coverage minimal none; do
HOME="$package_home" \
RUNNER_TEMP="$package_temp" \
PATH="$package_bin:$PATH" \
Expand Down Expand Up @@ -559,7 +559,7 @@ for contract in \
'cargo-deny =0.20.2' \
'cargo-audit =0.22.2' \
'cargo-rail =0.20.0' \
'cargo-semver-checks =0.48.0' \
'cargo-semver-checks =0.50.0' \
'just =1.57.0' \
'zizmor =1.26.1' \
'cargo-criterion =1.1.0' \
Expand Down