Implement the manual release procedure for reproducing milestone releases locally when Argo Workflows in iad-ci is degraded or unavailable. This is the PB-13 fallback documented in the plan (line 567) for the R13 risk register entry. The runbook includes: - Prerequisites (hardware, tools, cross-compilation toolchains) - OpenBao secret paths for all release credentials - 13-step release procedure covering: 1. Tag verification 2. Full CI suite run 3. Cross-compilation for 5 target triples × 2 feature variants 4. Binary verification 5. SHA-256 checksum generation 6. GPG signing of checksums 7. Python wheel building (maturin) 8. PyPI upload 9. crates.io publishing (pdftract-core → pdftract-cli order) 10. GitHub Release creation 11. mdBook building 12. Cloudflare Pages deployment 13. SLSA Level 2 attestation generation - Failure mode recovery procedures (triple build failure, PyPI upload failure, SLSA attestation failure) - Idempotency and safe re-run rules per step - Completion criteria (all channels must succeed) - Continuity plan (written for a stranger) Acceptance criteria: - docs/operations/manual-release.md exists with all required sections - Step-by-step procedure complete (all 13 steps) - Manual release CHANGELOG record template present - Failure modes documented for the three most likely partial failures - Runbook is verbatim-executable by a non-author release lead Closes: pdftract-4sj0
21 KiB
Manual Release Runbook (PB-13)
Purpose: This runbook is the fallback procedure for reproducing the milestone release locally when the Argo Workflows runner in
iad-ciis degraded or unavailable for a prolonged window. Per PB-13 (plan line 567), this is the R13 mitigation strategy.
Trigger: Argo Workflows in iad-ci cluster is unavailable for > 4 hours during a release window, or the release lead determines that automated release cannot proceed.
Executor: Release lead or designated engineer with access to OpenBao secrets and cross-compilation toolchains.
Prerequisites
Hardware and OS
- A clean Linux build environment (preferably the same Hetzner host that runs Argo; developer laptops are acceptable but NOT preferred due to build environment differences)
- At least 100 GB free disk space for Rust target directories and cross-compilation
- Stable internet connection for fetching crates and pushing artifacts
Installed Tools
# Verify all required tools are present
cargo --version # >= 1.83
rustup --version
maturin --version # >= 1.0
mdbook --version # >= 0.12
jq --version
gh --version # GitHub CLI
git --version
shasum --version # or sha256sum
gpg --version # for signing checksums
wrangler --version # Cloudflare Pages CLI
python3 --version # >= 3.11
Cross-Compilation Toolchains
All five target triples MUST be available:
| Target Triple | Toolchain Install Command |
|---|---|
x86_64-unknown-linux-musl |
rustup target add x86_64-unknown-linux-musl |
aarch64-unknown-linux-musl |
rustup target add aarch64-unknown-linux-musl |
x86_64-apple-darwin |
rustup target add x86_64-apple-darwin |
aarch64-apple-darwin |
rustup target add aarch64-apple-darwin |
x86_64-pc-windows-gnu |
rustup target add x86_64-pc-windows-gnu |
Install cross for cross-compilation:
cargo install cross --locked
OpenBao Secrets Access
The following secrets MUST be accessible via OpenBao (same ESO-style read access that Argo uses):
| Secret | OpenBao Path | Purpose |
|---|---|---|
| PyPI token | rs-manager/iad-ci/pypi/pdftract |
Upload Python wheels to PyPI |
| crates.io token | rs-manager/iad-ci/crates-io/pdftract |
Publish Rust crates to crates.io |
| GitHub Release token | rs-manager/iad-ci/github/pat/pdftract |
Create GitHub Release with artifacts |
| Cloudflare token | rs-manager/iad-ci/cloudflare/api_token |
Deploy docs to Cloudflare Pages |
| GPG signing key | rs-manager/iad-ci/gpg/pdftract_signing_key |
Sign checksums for release artifacts |
DO NOT copy secrets to disk on the developer machine. Fetch them via environment variables or inject them directly into commands.
Step-by-Step Release Procedure
Step 1: Verify the Tag
Ensure the tag matches the planned version and is annotated (not lightweight):
git fetch --tags
git describe --exact-match
Expected output: vX.Y.Z where X.Y.Z matches the planned release version.
If the command fails, you are not on a tagged commit. Checkout the correct tag:
git checkout vX.Y.Z
Verify the tag is annotated:
git tag -l vX.Y.Z -n9
Annotated tags show a message; lightweight tags do not. Only annotated tags are acceptable for releases.
Step 2: Run Full CI Suite Locally
Before building, verify the code passes all tests:
cd /path/to/pdftract
cargo test --workspace --all-features
Expected: All tests pass (exit code 0).
If tests fail, DO NOT PROCEED. Fix the failures and create a new tag.
Step 3: Cross-Compile Binaries for All Triples
Build all 10 binary archives (5 triples × 2 feature variants: default, full):
# Set environment variables for reproducible builds
export SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)
export CARGO_TARGET_DIR=./target
# Build matrix
TARGETS=(
"x86_64-unknown-linux-musl"
"aarch64-unknown-linux-musl"
"x86_64-apple-darwin"
"aarch64-apple-darwin"
"x86_64-pc-windows-gnu"
)
FEATURES=(
"default"
"full"
)
for TARGET in "${TARGETS[@]}"; do
for FEATURES in "${FEATURES[@]}"; do
echo "Building ${TARGET} (features: ${FEATURES})..."
FEATURE_FLAG=""
if [ "${FEATURES}" != "default" ]; then
FEATURE_FLAG="--features ${FEATURES}"
fi
cross build --release --target ${TARGET} ${FEATURE_FLAG} --locked --frozen
# Strip the binary
BINARY_PATH="target/${TARGET}/release/pdftract"
if [ "${TARGET}" = "x86_64-pc-windows-gnu" ]; then
BINARY_PATH="target/${TARGET}/release/pdftract.exe"
fi
# Strip (use appropriate strip command for target)
if [[ "${TARGET}" == *-apple-darwin ]]; then
# macOS targets use x86_64-apple-darwin-strip or aarch64-apple-darwin-strip
${TARGET%-unknown}-strip "${BINARY_PATH}"
else
strip "${BINARY_PATH}"
fi
done
done
Expected: All 10 builds complete without errors.
Step 4: Verify Each Binary Works
Quick smoke test on the native Linux binary:
./target/x86_64-unknown-linux-musl/release/pdftract --version
Expected: Version string matches the tag.
For cross-compiled binaries, verify the file output exists and is non-empty:
ls -lh target/*/release/pdftract*
Expected: All 10 binaries are present and non-zero size.
Step 5: Generate SHA-256 Checksums
Create checksums for all binary archives:
VERSION=$(git describe --exact-match | sed 's/^v//')
DIST_DIR="./dist"
mkdir -p ${DIST_DIR}
# Generate archives and checksums in one pass
cd ${DIST_DIR}
for TARGET in "${TARGETS[@]}"; do
for FEATURES in "${FEATURES[@]}"; do
ARCHIVE_NAME="pdftract"
if [ "${FEATURES}" != "default" ]; then
ARCHIVE_NAME="pdftract-${FEATURES}"
fi
ARCHIVE_DIR="${ARCHIVE_NAME}-v${VERSION}-${TARGET}"
mkdir -p "${ARCHIVE_DIR}"
# Copy binary
if [ "${TARGET}" = "x86_64-pc-windows-gnu" ]; then
cp "../target/${TARGET}/release/pdftract.exe" "${ARCHIVE_DIR}/"
else
cp "../target/${TARGET}/release/pdftract" "${ARCHIVE_DIR}/"
fi
# Copy license files
cp ../LICENSE-MIT "${ARCHIVE_DIR}/"
cp ../LICENSE-APACHE "${ARCHIVE_DIR}/"
cp ../README.md "${ARCHIVE_DIR}/"
# Extract CHANGELOG excerpt
python3 <<EOF
import re
version = "${VERSION}".replace(".", r"\.")
with open("../CHANGELOG.md", "r") as f:
content = f.read()
pattern = rf"^## \[{version}\](?:.*?)(?=^## |\Z)"
match = re.search(pattern, content, re.MULTILINE | re.DOTALL)
if match:
with open("${ARCHIVE_DIR}/CHANGELOG.md", "w") as out:
out.write(f"## [{version}]\n")
out.write(match.group(0))
else:
with open("${ARCHIVE_DIR}/CHANGELOG.md", "w") as out:
out.write(f"## [{version}]\n\nSee https://github.com/jedarden/pdftract/releases/tag/v${VERSION}\n")
EOF
# Create archive
if [ "${TARGET}" = "x86_64-pc-windows-gnu" ]; then
ARCHIVE_FILE="${ARCHIVE_NAME}-v${VERSION}-${TARGET}.zip"
zip -r "${ARCHIVE_FILE}" "${ARCHIVE_DIR}"
else
ARCHIVE_FILE="${ARCHIVE_NAME}-v${VERSION}-${TARGET}.tar.gz"
tar czf "${ARCHIVE_FILE}" "${ARCHIVE_DIR}"
fi
done
done
# Generate checksums
shasum -a 256 pdftract*.tar.gz pdftract*.zip > CHECKSUMS.sha256
Expected: CHECKSUMS.sha256 contains SHA-256 hashes for all 10 archives.
Step 6: GPG Sign the Checksums File
Sign the checksums file with the GPG key:
# Fetch GPG key from OpenBao (example via environment variable)
export GPG_KEY=$(bao read -field=private_key rs-manager/iad-ci/gpg/pdftract_signing_key)
# Import key temporarily (DO NOT persist to disk)
gpg --import <<< "${GPG_KEY}"
# Sign the checksums
gpg --detach-sign --digest-algo SHA256 --armor --output CHECKSUMS.sha256.asc CHECKSUMS.sha256
# Verify the signature
gpg --verify CHECKSUMS.sha256.asc CHECKSUMS.sha256
Expected: gpg: Good signature from the pdftract signing key.
Step 7: Build Python Wheels
Build wheels for all three platforms (Linux, macOS, Windows):
# Install maturin if not present
cargo install maturin --locked
# Build wheels (this uses cross for non-native platforms)
maturin build --release --strip
# Wheels are in target/wheels/
ls -lh target/wheels/
Expected: Wheels for pdftract and pdftract-core are present in target/wheels/.
Step 8: Upload Wheels to PyPI
# Fetch PyPI token from OpenBao
export PYPI_TOKEN=$(bao read -field=token rs-manager/iad-ci/pypi/pdftract)
# Upload using maturin (token is passed via environment variable)
echo "pypi-token = ${PYPI_TOKEN}" > ~/.pypirc
maturin upload --repository pypi target/wheels/*.whl
Expected: All wheels upload successfully (HTTP 200 responses).
Failure mode - PyPI upload fails midway:
- If upload fails mid-stream, DO NOT force overwrite (
--skip-existingis acceptable for re-running) - Check PyPI status page: https://status.pypi.org/
- If PyPI is down, retry in 5 minutes
- If a specific wheel fails, check the wheel with
unzip -land verify it's not corrupted - Document the failure and proceed to other channels (crates.io, GitHub Release can still succeed)
Step 9: Publish to crates.io
Publish pdftract-core first, then pdftract-cli (dependency order matters):
# Fetch crates.io token from OpenBao
export CARGO_REGISTRY_TOKEN=$(bao read -field=token rs-manager/iad-ci/crates-io/pdftract)
# Publish pdftract-core first
cargo publish --package pdftract-core
# Wait for index propagation (crates.io can take 1-5 minutes)
echo "Waiting for pdftract-core to appear in the index..."
sleep 60 # Wait at least 1 minute
# Verify pdftract-core is available
cargo search pdftract-core --limit 1
# Publish pdftract-cli
cargo publish --package pdftract-cli
Expected: Both crates publish successfully.
Failure mode - crates.io publish fails:
- Check if the version already exists (this is a hard error from crates.io)
- If it's a network error, retry after 60 seconds
- If it's a validation error, fix
Cargo.tomland create a new tag - Document the failure and proceed to other channels
Step 10: Create GitHub Release
VERSION=$(git describe --exact-match | sed 's/^v//')
REPO="jedarden/pdftract"
# Fetch GitHub token from OpenBao
export GH_TOKEN=$(bao read -field=token rs-manager/iad-ci/github/pat/pdftract)
# Create release with all artifacts
gh release create v${VERSION} \
./dist/pdftract*.tar.gz \
./dist/pdftract*.zip \
./dist/CHECKSUMS.sha256 \
./dist/CHECKSUMS.sha256.asc \
--title "pdftract v${VERSION}" \
--notes "Release notes: https://github.com/${REPO}/blob/v${VERSION}/CHANGELOG.md"
Expected: GitHub Release created at https://github.com/jedarden/pdftract/releases/tag/v${VERSION}.
Step 11: Build mdBook
cd docs/user-docs
mdbook build
Expected: docs/user-docs/book/ directory contains the built HTML site.
Step 12: Deploy Docs to Cloudflare Pages
# Fetch Cloudflare token from OpenBao
export CLOUDFLARE_API_TOKEN=$(bao read -field=token rs-manager/iad-ci/cloudflare/api_token)
# Deploy using wrangler
cd docs/user-docs
wrangler pages publish build --project-name=pdftract-docs
Expected: Docs deployed to https://pdftract.com (or the configured Pages domain).
Failure mode - wrangler publish fails:
- Check Cloudflare status: https://www.cloudflarestatus.com/
- Verify the project name matches your Cloudflare Pages project
- If the build directory is missing, re-run
mdbook build - Document the failure and proceed (docs can be deployed later)
Step 13: Generate SLSA Level 2 Attestation
Generate SLSA Provenance v1.0 predicate for the release artifacts:
python3 <<'EOF'
import hashlib
import json
import subprocess
from datetime import datetime, timezone
VERSION = "${VERSION}"
COMMIT_SHA = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip()
TAG = "v${VERSION}"
REPO = "jedarden/pdftract"
# Compute SOURCE_DATE_EPOCH for reproducibility
SOURCE_DATE_EPOCH = subprocess.check_output(
["git", "show", "-s", "--format=%ct", "HEAD"]
).decode().strip()
# Collect all binary archives
subjects = []
for f in ["pdftract*.tar.gz", "pdftract*.zip"]:
for path in glob.glob(f):
sha256 = hashlib.sha256()
with open(path, "rb") as fp:
for chunk in iter(lambda: fp.read(65536), b""):
sha256.update(chunk)
digest = sha256.hexdigest()
subjects.append({
"name": os.path.basename(path),
"digest": {"sha256": digest}
})
# Build invocation ID
invocation_id = f"manual-release-{COMMIT_SHA}-{TAG}"
# Build timestamp
started_on = datetime.fromtimestamp(int(SOURCE_DATE_EPOCH), tz=timezone.utc).isoformat()
finished_on = datetime.now(timezone.utc).isoformat()
# Construct SLSA Provenance v1.0 predicate
provenance = {
"_type": "https://in-toto.io/Statement/v1",
"subject": subjects,
"predicateType": "https://slsa.dev/provenance/v1.0",
"predicate": {
"buildDefinition": {
"buildType": "https://github.com/jedarden/pdftract/releases/manual",
"externalParameters": {
"tag": TAG,
"version": VERSION,
"commit": COMMIT_SHA,
"source": f"https://github.com/{REPO}"
},
"internalParameters": {
"workflow": "manual-release",
"targetTriples": [
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-gnu"
]
},
"resolvedDependencies": [
{
"uri": f"git+https://github.com/{REPO}@{COMMIT_SHA}",
"digest": {"gitCommit": COMMIT_SHA}
}
]
},
"runDetails": {
"builder": {
"id": "https://github.com/jedarden/pdftract/releases/manual",
"builderDependencies": [],
"version": {
"manual": "PB-13-fallback"
}
},
"metadata": {
"invocationId": invocation_id,
"startedOn": started_on,
"finishedOn": finished_on
},
"byproducts": []
}
}
}
# Write to multiple.intoto.jsonl
with open("multiple.intoto.jsonl", "w") as f:
f.write(json.dumps(provenance) + "\n")
print(f"SLSA provenance written to multiple.intoto.jsonl")
print(f"Subjects: {len(subjects)}")
EOF
Upload the attestation to the GitHub Release:
gh release upload v${VERSION} multiple.intoto.jsonl
Expected: multiple.intoto.jsonl appears in the GitHub Release assets.
Failure mode - SLSA attestation generation fails:
- The release can still ship without SLSA attestation, but document this in the CHANGELOG
- Add a note:
This release was produced via the PB-13 manual fallback; SLSA Level 2 attestation is not available. - This is a degraded outcome; restore Argo-driven releases for the next milestone
Post-Release Manual Release Record
Append a manual release record to CHANGELOG.md:
## [Unreleased]
### Release Operations
- **vX.Y.Z (YYYY-MM-DD)**: Manual release (PB-13 fallback)
- Reason: Argo Workflows in `iad-ci` degraded for <duration>
- Release lead: <your initials>
- All channels completed successfully: PyPI, crates.io, GitHub Release, Cloudflare Pages
- Next milestone: Resume Argo-driven releases
Commit this change:
git add CHANGELOG.md
git commit -m "docs(changelog): record manual release v${VERSION} (PB-13 fallback)"
git push
Failure Modes and Recovery
Failure Mode 1: One Triple Build Fails
Symptom: Cross-compilation fails for a single target triple (e.g., aarch64-apple-darwin fails, but others succeed).
Can the release proceed? NO. All five triples MUST ship.
Recovery:
-
Check the error message:
- Missing SDK? Install the required toolchain (e.g., osxcross for macOS targets)
- Compilation error? Fix the code and create a new tag
- Timeout? Increase resources and retry
-
If the issue is environmental (e.g., macOS SDK unavailable), document the missing triple and proceed with a partial release ONLY IF the release lead approves. This is a degraded release.
-
Open a follow-up issue to fix the missing triple for the next release.
Failure Mode 2: PyPI Upload Fails Midway
Symptom: maturin upload fails partway through uploading wheels.
Can the release proceed? YES, to other channels. PyPI can be retried.
Recovery:
-
Check PyPI status: https://status.pypi.org/
-
Retry the upload with
--skip-existingflag:maturin upload --repository pypi --skip-existing target/wheels/*.whl -
If the failure persists, check the wheel integrity:
unzip -l target/wheels/pdftract-*.whl -
Document the failure and proceed to crates.io and GitHub Release. PyPI can be fixed post-release.
DO NOT force overwrite (--force) on PyPI. This can corrupt the index.
Failure Mode 3: SLSA Attestation Generation Fails
Symptom: The SLSA provenance script errors out or produces invalid JSON.
Can the release proceed? YES, but this is a degraded outcome.
Recovery:
-
Check the error:
- Missing
gitcommand? Installgit - Invalid JSON? Check the Python script syntax
- Missing artifacts? Re-run Step 5 (checksums)
- Missing
-
If the failure cannot be resolved, proceed without SLSA attestation. Add a note to the GitHub Release description:
Note: This release was produced via the PB-13 manual fallback. SLSA Level 2 attestation is not available for this release.
-
Open a follow-up issue to restore SLSA generation for the next release.
-
Update the CHANGELOG with the degraded outcome:
- **vX.Y.Z (YYYY-MM-DD)**: Manual release (PB-13 fallback, DEGRADED) - SLSA Level 2 attestation not available
Idempotency and Safe Re-Run Rules
Each step in this procedure is designed to be safe to re-run after a transient failure:
| Step | Safe to Re-Run? | Notes |
|---|---|---|
| Step 1: Verify Tag | YES | No state changes |
| Step 2: Run Tests | YES | No state changes |
| Step 3: Build Binaries | YES | cargo build is idempotent |
| Step 4: Verify Binaries | YES | No state changes |
| Step 5: Generate Checksums | YES | Re-running overwrites CHECKSUMS.sha256 |
| Step 6: GPG Sign | YES | Re-running overwrites CHECKSUMS.sha256.asc |
| Step 7: Build Wheels | YES | maturin build is idempotent |
| Step 8: PyPI Upload | YES | Use --skip-existing on re-run |
| Step 9: crates.io Publish | NO | Version cannot be overwritten |
| Step 10: GitHub Release | NO | Release cannot be overwritten (must delete and recreate) |
| Step 11: Build mdBook | YES | mdbook build is idempotent |
| Step 12: Deploy Docs | YES | Wrangler overwrites the existing deployment |
| Step 13: SLSA Attestation | YES | Re-running overwrites multiple.intoto.jsonl |
Special Re-Run Cases:
-
Step 9 (crates.io): If publish fails, you cannot retry the same version. You must fix the issue, create a new tag (
vX.Y.Z+1), and restart the release. -
Step 10 (GitHub Release): If release creation fails, delete the draft release and retry:
gh release delete v${VERSION} --yes gh release create v${VERSION} [...]
Completion Criteria
The manual release is complete when:
- All 10 binary archives are built and available on the GitHub Release.
- PyPI wheels are published and installable (
pip install pdftractworks). - crates.io packages are published and usable (
cargo install pdftractworks). - GitHub Release exists with all artifacts, checksums, and GPG signature.
- Docs are deployed to Cloudflare Pages and accessible.
- SLSA attestation is attached to the GitHub Release (or degraded outcome is documented).
- CHANGELOG.md includes the manual release record.
The manual release is blocked when:
- Any single triple build fails (all five MUST ship).
cargo testfails (no broken releases).- crates.io publish fails (version collision is a hard stop).
- GitHub Release creation fails (release must exist for users to download artifacts).
On blockage: Open an incident issue, document the failure, and notify the project lead. The milestone release is delayed until the blocker is resolved.
References
- Bead:
pdftract-4sj0(this runbook) - Plan: R13 risk register (line 549), PB-13 fallback procedure (line 567)
- Sibling workflows:
pdftract-release-cascade(Argo orchestration template)pdftract-build-binaries(Argo build template)pdftract-crates-publish(Argo crates.io publish)pdftract-py-ci(Argo PyPI upload)pdftract-github-release(Argo GitHub Release creation)pdftract-docs-build(Argo mdBook deployment)
- External Secret paths:
rs-manager/iad-ci/pypi/pdftractrs-manager/iad-ci/crates-io/pdftractrs-manager/iad-ci/github/pat/pdftractrs-manager/iad-ci/cloudflare/api_tokenrs-manager/iad-ci/gpg/pdftract_signing_key
Continuity Plan
This runbook is written for a stranger. If the release lead is unavailable, any engineer with:
- Access to the OpenBao secret paths listed above
- A Linux build environment with the prerequisites installed
- Git access to
jedarden/pdftract
...should be able to execute this procedure and ship the release.
If you find this runbook unclear or missing steps, open an issue to improve it for the next PB-13 activation.