Skip to content

Controlled Release

Controlled Release #6

# Controlled Release Workflow with Dual Approval Gates
#
# This workflow implements a secure release process with two manual approval
# checkpoints before publishing the package. It ensures that releases are
# thoroughly vetted and approved by multiple maintainers.
#
# SETUP REQUIRED:
# Before using this workflow, you must create two GitHub Environments:
#
# 1. "pre-release-review" environment:
# - Go to Settings → Environments → New environment
# - Name: pre-release-review
# - Enable "Required reviewers"
# - Add 1-2 reviewers who must approve before proceeding
#
# 2. "public-release" environment:
# - Go to Settings → Environments → New environment
# - Name: public-release
# - Enable "Required reviewers"
# - Add 1-2 DIFFERENT reviewers (for dual approval)
# - Optional: Enable "Wait timer" for a cooling-off period
# - NOTE: If you already have this environment, just verify it has required reviewers enabled
#
# USAGE:
# To trigger a release:
# 1. Push a git tag matching v* (e.g., v0.1.0, v1.2.3)
# git tag -a v0.1.0 -m "Release version 0.1.0"
# git push origin v0.1.0
# 2. The workflow will automatically start
# 3. First approval gate: review validation results
# 4. Second approval gate: approve final release to CRAN/GitHub
#
# WORKFLOW STAGES:
# 1. Validation: Run all checks, tests, and build package tarball
# 2. GATE 1 (pre-release-review): Manual approval after reviewing validation
# 3. Pre-release: Create draft GitHub release with artifacts
# 4. GATE 2 (public-release): Final manual approval before publishing
# 5. Publish: Release to GitHub and optionally submit to CRAN
name: Controlled Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 0.1.0)'
required: true
type: string
env:
R_VERSION: 'release'
jobs:
# STAGE 1: Validation
# Runs all quality checks without requiring approval
validate:
name: Validation - Quality Checks
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tarball: ${{ steps.build.outputs.tarball }}
steps:
- name: Extract version from tag or input
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${GITHUB_REF#refs/tags/v}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Release version: $VERSION"
- uses: actions/checkout@v4
- uses: r-lib/actions/setup-r@v2
with:
r-version: ${{ env.R_VERSION }}
use-public-rspm: true
- uses: r-lib/actions/setup-r-dependencies@v2
with:
extra-packages: any::rcmdcheck, any::pkgbuild, any::covr, any::urlchecker, any::spelling
needs: check
- name: Verify version consistency across all metadata files
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "=== Comprehensive Version Validation ==="
echo "Expected version (from tag): $VERSION"
echo ""
# Run our comprehensive version consistency checker
Rscript .dev/check-version-consistency.R
if [ $? -ne 0 ]; then
echo ""
echo "ERROR: Version consistency check failed!"
echo "All metadata files must have matching versions before release."
exit 1
fi
# Verify tag matches DESCRIPTION
DESC_VERSION=$(Rscript -e "cat(as.character(desc::desc_get_version()))")
if [ "$VERSION" != "$DESC_VERSION" ]; then
echo ""
echo "ERROR: Git tag version ($VERSION) does not match DESCRIPTION version ($DESC_VERSION)"
echo "Ensure the tag matches the version in DESCRIPTION file."
exit 1
fi
echo ""
echo "✓ All version validation checks passed"
echo " - DESCRIPTION version matches tag"
echo " - CITATION versions consistent"
echo " - .zenodo.json version consistent"
echo " - All metadata files in sync"
- name: CRAN Readiness - Check URLs
run: |
Rscript -e "
cat('\n=== URL Validation ===\n')
urlchecker::url_check()
"
- name: CRAN Readiness - Check Spelling
run: |
Rscript -e "
cat('\n=== Spell Check ===\n')
spelling::spell_check_package()
"
continue-on-error: true
- name: Run R CMD check with --as-cran
uses: r-lib/actions/check-r-package@v2
with:
build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")'
args: 'c("--as-cran")'
error-on: '"error"'
- name: Run test suite with coverage
run: |
Rscript -e '
covr_results <- covr::package_coverage(quiet = FALSE)
covr_percent <- covr::percent_coverage(covr_results)
cat(sprintf("\n=== Test Coverage: %.1f%% ===\n", covr_percent))
if (covr_percent < 30) {
stop("Coverage below 30% threshold: ", covr_percent, "%")
}
'
- name: Build package tarball
id: build
run: |
# Build package and capture only the tarball path (last line of output)
TARBALL=$(Rscript -e 'path <- pkgbuild::build(dest_path = ".", binary = FALSE, vignettes = TRUE, manual = TRUE); cat(path, "\n", sep="", file=stderr()); cat(path)' 2>&1 | tail -1)
echo "tarball=$TARBALL" >> $GITHUB_OUTPUT
echo "Built package tarball: $TARBALL"
# Verify tarball exists and is valid
if [ ! -f "$TARBALL" ]; then
echo "Error: Tarball not found: $TARBALL"
exit 1
fi
tar -tzf "$TARBALL" > /dev/null
echo "✓ Tarball verification passed"
- name: Upload package tarball
uses: actions/upload-artifact@v4
with:
name: package-tarball
path: ${{ steps.build.outputs.tarball }}
retention-days: 30
- name: Generate validation report
run: |
cat > validation-report.md <<EOF
# Release Validation Report
**Version:** ${{ steps.version.outputs.version }}
**Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC")
**Commit:** ${{ github.sha }}
## Quality Checks Status
- ✅ R CMD check --as-cran passed (0 errors, 0 warnings)
- ✅ URL validation passed
- ✅ Spell check completed
- ✅ Test suite passed with adequate coverage
- ✅ Package tarball built successfully
- ✅ Version consistency verified
- ✅ CRAN readiness validated
## Package Artifact
- **Tarball:** ${{ steps.build.outputs.tarball }}
## Next Steps
Review this validation report and approve at the **pre-release-review** gate
if all checks are satisfactory.
EOF
cat validation-report.md
- name: Upload validation report
uses: actions/upload-artifact@v4
with:
name: validation-report
path: validation-report.md
# GATE 1: Pre-Release Review
# Manual approval required after reviewing validation results
pre-release-review:
name: Gate 1 - Pre-Release Review
needs: validate
runs-on: ubuntu-latest
environment: pre-release-review
steps:
- name: Download validation report
uses: actions/download-artifact@v4
with:
name: validation-report
- name: Display validation report
run: |
echo "=== VALIDATION REPORT FOR REVIEW ==="
cat validation-report.md
echo ""
echo "=== APPROVAL GATE 1 ==="
echo "If validation looks good, approve this environment to proceed to pre-release."
- name: Record approval
run: |
echo "Pre-release review approved at $(date -u +"%Y-%m-%d %H:%M:%S UTC")"
echo "Approved by: ${{ github.actor }}"
# STAGE 2: Create Pre-Release
# Creates a draft GitHub release with the package tarball
create-pre-release:
name: Create Draft Release
needs: [validate, pre-release-review]
runs-on: ubuntu-latest
outputs:
release-id: ${{ steps.create-release.outputs.id }}
upload-url: ${{ steps.create-release.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Download package tarball
uses: actions/download-artifact@v4
with:
name: package-tarball
- name: Download validation report
uses: actions/download-artifact@v4
with:
name: validation-report
- name: Generate release notes
id: release-notes
run: |
VERSION="${{ needs.validate.outputs.version }}"
# Extract NEWS.md section for this version if it exists
if [ -f "NEWS.md" ]; then
awk "/^# emburden $VERSION/,/^# emburden [0-9]/" NEWS.md | head -n -1 > release-notes.md
else
echo "Release notes not found in NEWS.md" > release-notes.md
fi
# Append validation summary
cat >> release-notes.md <<EOF
---
## Package Validation
All quality checks passed:
- ✅ R CMD check (0 errors, 0 warnings)
- ✅ Test coverage above threshold
- ✅ Version consistency verified
See attached validation report for details.
## Installation
\`\`\`r
# Install from GitHub
# install.packages("remotes")
remotes::install_github("ericscheier/emburden@v$VERSION")
\`\`\`
## Archive DOI
This release is archived on Zenodo with a permanent DOI for citation.
EOF
cat release-notes.md
- name: Create draft GitHub release
id: create-release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.PUBLIC_REPO_TOKEN }}
with:
tag_name: v${{ needs.validate.outputs.version }}
release_name: emburden v${{ needs.validate.outputs.version }}
body_path: release-notes.md
draft: true
prerelease: false
- name: Upload tarball to release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.PUBLIC_REPO_TOKEN }}
with:
upload_url: ${{ steps.create-release.outputs.upload_url }}
asset_path: ${{ needs.validate.outputs.tarball }}
asset_name: ${{ needs.validate.outputs.tarball }}
asset_content_type: application/gzip
- name: Upload validation report to release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.PUBLIC_REPO_TOKEN }}
with:
upload_url: ${{ steps.create-release.outputs.upload_url }}
asset_path: validation-report.md
asset_name: validation-report.md
asset_content_type: text/markdown
# GATE 2: Production Release Approval
# Final manual approval before publishing
production-release-review:
name: Gate 2 - Production Release Approval
needs: [validate, create-pre-release]
runs-on: ubuntu-latest
environment: public-release
steps:
- name: Review release
run: |
echo "=== FINAL APPROVAL GATE ==="
echo "Version: ${{ needs.validate.outputs.version }}"
echo "Draft release created: ${{ needs.create-pre-release.outputs.release-id }}"
echo ""
echo "Review the draft release at:"
echo "https://github.com/${{ github.repository }}/releases"
echo ""
echo "If everything looks good, approve this environment to publish the release."
- name: Record final approval
run: |
echo "Production release approved at $(date -u +"%Y-%m-%d %H:%M:%S UTC")"
echo "Approved by: ${{ github.actor }}"
# STAGE 3: Publish Release
# Publishes the GitHub release (triggers Zenodo archival)
publish-release:
name: Publish Release
needs: [validate, create-pre-release, production-release-review]
runs-on: ubuntu-latest
steps:
- name: Publish GitHub release
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PUBLIC_REPO_TOKEN }}
script: |
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: ${{ needs.create-pre-release.outputs.release-id }},
draft: false
});
console.log('✅ Release published successfully');
- name: Post-release summary
run: |
cat <<EOF
================================================
🎉 RELEASE PUBLISHED SUCCESSFULLY 🎉
================================================
Version: v${{ needs.validate.outputs.version }}
Release URL: https://github.com/${{ github.repository }}/releases/tag/v${{ needs.validate.outputs.version }}
✅ Dual approval gates passed
✅ GitHub release published
✅ Zenodo archival will be triggered automatically
NEXT STEPS (Manual):
1. Verify Zenodo DOI appears on the release page
2. If submitting to CRAN:
- Download the package tarball from the release
- Submit via https://cran.r-project.org/submit.html
- Monitor CRAN submission status
3. Announce the release:
- Update package website if applicable
- Social media announcements
- Notify collaborators
================================================
EOF
# Optional: CRAN submission preparation
# This job provides instructions but doesn't auto-submit
# (CRAN submissions must be done manually)
cran-submission-info:
name: CRAN Submission Information
needs: [validate, publish-release]
runs-on: ubuntu-latest
if: success()
steps:
- name: CRAN submission instructions
run: |
cat <<EOF
========================================
📦 CRAN SUBMISSION INSTRUCTIONS
========================================
The package is now ready for CRAN submission.
IMPORTANT: CRAN submissions must be done manually by the package maintainer.
Steps to submit:
0. Pre-submission Windows check (RECOMMENDED):
Run devtools::check_win_devel() to test on Windows R-devel
- This submits to CRAN's win-builder service
- Results arrive via email (typically within 30-60 minutes)
- Fix any Windows-specific issues before submitting to CRAN
- Command: Rscript -e "devtools::check_win_devel()"
1. Download the package tarball:
https://github.com/${{ github.repository }}/releases/download/v${{ needs.validate.outputs.version }}/${{ needs.validate.outputs.tarball }}
2. Review CRAN submission checklist:
- R CMD check passes with 0 errors, 0 warnings ✅
- Package builds successfully ✅
- All tests pass ✅
- NEWS.md updated with version notes
- DESCRIPTION file has correct maintainer email
- All examples run successfully
- Windows check completed (if applicable)
3. Submit to CRAN:
- Go to: https://cran.r-project.org/submit.html
- Upload: ${{ needs.validate.outputs.tarball }}
- Fill in submission form
- Confirm maintainer email
4. Monitor submission:
- Check email for CRAN automated checks
- Respond to any reviewer feedback
- Typical review time: 2-7 days
5. After CRAN acceptance:
- Package appears on CRAN within 24 hours
- Update README badges if needed
========================================
EOF