name: Release on: push: tags: - 'v*.*.*' env: # https://github.com/actions/setup-go/issues/491 GOTOOLCHAIN: local permissions: contents: read jobs: verify-tag: name: Verify Tag Signature and Approvals runs-on: ubuntu-latest permissions: contents: write pull-requests: write issues: write outputs: verification_passed: ${{ steps.verify.outputs.passed }} tag_version: ${{ steps.info.outputs.version }} proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }} steps: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 # Force fetch upstream tags -- because 65 minutes # tl;dr: actions/checkout@v3 runs this line: # git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/ # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran: # git fetch --prune --unshallow # which doesn't overwrite that tag because that would be destructive. # Credit to @francislavoie for the investigation. # https://github.com/actions/checkout/issues/290#issuecomment-680260080 - name: Force fetch upstream tags run: git fetch --tags --force - name: Get tag info id: info run: | echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 - name: Print Go version and environment id: vars run: | printf "Using go at: $(which go)\n" printf "Go version: $(go version)\n" printf "\n\nGo environment:\n\n" go env printf "\n\nSystem environment:\n\n" env echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT # Add "pip install" CLI tools to PATH echo ~/.local/bin >> $GITHUB_PATH # Parse semver TAG=${GITHUB_REF/refs\/tags\//} SEMVER_RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z\.-]*\)' TAG_MAJOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\1#"` TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"` TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"` TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"` echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT - name: Validate commits and tag signatures id: verify env: signing_keys: ${{ secrets.SIGNING_KEYS }} run: | # Read the string into an array, splitting by IFS IFS=";" read -ra keys_collection <<< "$signing_keys" # ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-the-runner-context touch "${{ runner.temp }}/allowed_signers" # Iterate and print the split elements for item in "${keys_collection[@]}"; do # trim leading whitespaces item="${item##*( )}" # trim trailing whitespaces item="${item%%*( )}" IFS=" " read -ra key_components <<< "$item" # git wants it in format: email address, type, public key # ssh has it in format: type, public key, email address echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "${{ runner.temp }}/allowed_signers" done git config set --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers" echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}" # Verify the tag is signed if ! git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1; then echo "❌ Tag verification failed!" echo "passed=false" >> $GITHUB_OUTPUT git push --delete origin "${{ steps.vars.outputs.version_tag }}" exit 1 fi # Run it again to capture the output git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1 | tee /tmp/verify-output.txt; # SSH verification output typically includes the key fingerprint # Use GNU grep with Perl regex for cleaner extraction (Linux environment) KEY_SHA256=$(grep -oP "SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "") if [ -z "$KEY_SHA256" ]; then # Try alternative pattern with "key" prefix KEY_SHA256=$(grep -oP "key SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "") fi if [ -z "$KEY_SHA256" ]; then # Fallback: extract any base64-like string (40+ chars) KEY_SHA256=$(grep -oP '[A-Za-z0-9+/]{40,}=?' /tmp/verify-output.txt | head -1 || echo "") fi if [ -z "$KEY_SHA256" ]; then echo "Somehow could not extract SSH key fingerprint from git verify-tag output" echo "Cancelling flow and deleting tag" echo "passed=false" >> $GITHUB_OUTPUT git push --delete origin "${{ steps.vars.outputs.version_tag }}" exit 1 fi echo "✅ Tag verification succeeded!" echo "passed=true" >> $GITHUB_OUTPUT echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT - name: Find related release proposal id: find_proposal uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const version = '${{ steps.vars.outputs.version_tag }}'; // Search for PRs with release-proposal label that match this version const prs = await github.rest.pulls.list({ owner: context.repo.owner, repo: context.repo.repo, state: 'open', // Changed to 'all' to find both open and closed PRs sort: 'updated', direction: 'desc' }); // Find the most recent PR for this version const proposal = prs.data.find(pr => pr.title.includes(version) && pr.labels.some(label => label.name === 'release-proposal') ); if (!proposal) { console.log(`⚠️ No release proposal PR found for ${version}`); console.log('This might be a hotfix or emergency release'); return { number: null, approved: true, approvals: 0, proposedCommit: null }; } console.log(`Found proposal PR #${proposal.number} for version ${version}`); // Extract commit hash from PR body const commitMatch = proposal.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/); const proposedCommit = commitMatch ? commitMatch[1] : null; if (proposedCommit) { console.log(`Proposal was for commit: ${proposedCommit}`); } else { console.log('⚠️ No target commit hash found in PR body'); } // Get PR reviews to extract approvers let approvers = 'Validated by automation'; let approvalCount = 2; // Minimum required try { const reviews = await github.rest.pulls.listReviews({ owner: context.repo.owner, repo: context.repo.repo, pull_number: proposal.number }); // Get latest review per user and filter for approvals const latestReviewsByUser = {}; reviews.data.forEach(review => { const username = review.user.login; if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) { latestReviewsByUser[username] = review; } }); const approvalReviews = Object.values(latestReviewsByUser).filter(review => review.state === 'APPROVED' ); if (approvalReviews.length > 0) { approvers = approvalReviews.map(r => '@' + r.user.login).join(', '); approvalCount = approvalReviews.length; console.log(`Found ${approvalCount} approvals from: ${approvers}`); } } catch (error) { console.log(`Could not fetch reviews: ${error.message}`); } return { number: proposal.number, approved: true, approvals: approvalCount, approvers: approvers, proposedCommit: proposedCommit }; result-encoding: json - name: Verify proposal commit run: | APPROVALS='${{ steps.find_proposal.outputs.result }}' # Parse JSON PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit') CURRENT_COMMIT="${{ steps.info.outputs.sha }}" echo "Proposed commit: $PROPOSED_COMMIT" echo "Current commit: $CURRENT_COMMIT" # Check if commits match (if proposal had a target commit) if [ "$PROPOSED_COMMIT" != "null" ] && [ -n "$PROPOSED_COMMIT" ]; then # Normalize both commits to full SHA for comparison PROPOSED_FULL=$(git rev-parse "$PROPOSED_COMMIT" 2>/dev/null || echo "") CURRENT_FULL=$(git rev-parse "$CURRENT_COMMIT" 2>/dev/null || echo "") if [ -z "$PROPOSED_FULL" ]; then echo "⚠️ Could not resolve proposed commit: $PROPOSED_COMMIT" elif [ "$PROPOSED_FULL" != "$CURRENT_FULL" ]; then echo "❌ Commit mismatch!" echo "The tag points to commit $CURRENT_FULL but the proposal was for $PROPOSED_FULL" echo "This indicates an error in tag creation." # Delete the tag remotely git push --delete origin "${{ steps.vars.outputs.version_tag }}" echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted" exit 1 else echo "✅ Commit hash matches proposal" fi else echo "⚠️ No target commit found in proposal (might be legacy release)" fi echo "✅ Tag verification completed" - name: Update release proposal PR if: fromJson(steps.find_proposal.outputs.result).number != null uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const result = ${{ steps.find_proposal.outputs.result }}; if (result.number) { // Add in-progress label await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: result.number, labels: ['release-in-progress'] }); // Remove approved label if present try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: result.number, name: 'approved' }); } catch (e) { console.log('Approved label not found:', e.message); } const commentBody = [ '## 🚀 Release Workflow Started', '', '- **Tag:** ${{ steps.info.outputs.version }}', '- **Signed by key:** ${{ steps.verify.outputs.key_id }}', '- **Commit:** ${{ steps.info.outputs.sha }}', '- **Approved by:** ' + result.approvers, '', 'Release workflow is now running. This PR will be updated when the release is published.' ].join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: result.number, body: commentBody }); } - name: Summary run: | APPROVALS='${{ steps.find_proposal.outputs.result }}' PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit // "N/A"') APPROVERS=$(echo "$APPROVALS" | jq -r '.approvers // "N/A"') echo "## Tag Verification Summary 🔐" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- **Tag:** ${{ steps.info.outputs.version }}" >> $GITHUB_STEP_SUMMARY echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY echo "- **Signature:** ✅ Verified" >> $GITHUB_STEP_SUMMARY echo "- **Signed by:** ${{ steps.verify.outputs.key_id }}" >> $GITHUB_STEP_SUMMARY echo "- **Approvals:** ✅ Sufficient" >> $GITHUB_STEP_SUMMARY echo "- **Approved by:** $APPROVERS" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Proceeding with release build..." >> $GITHUB_STEP_SUMMARY release: name: Release needs: verify-tag if: ${{ needs.verify-tag.outputs.verification_passed == 'true' }} strategy: matrix: os: - ubuntu-latest go: - '1.25' include: # Set the minimum Go patch version for the given Go minor # Usable via ${{ matrix.GO_SEMVER }} - go: '1.25' GO_SEMVER: '~1.25.0' runs-on: ${{ matrix.os }} # https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233 # https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings permissions: id-token: write # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps#permission-on-contents # "Releases" is part of `contents`, so it needs the `write` contents: write issues: write pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 with: egress-policy: audit - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 - name: Install Go uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version: ${{ matrix.GO_SEMVER }} check-latest: true # Force fetch upstream tags -- because 65 minutes # tl;dr: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2 runs this line: # git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/ # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran: # git fetch --prune --unshallow # which doesn't overwrite that tag because that would be destructive. # Credit to @francislavoie for the investigation. # https://github.com/actions/checkout/issues/290#issuecomment-680260080 - name: Force fetch upstream tags run: git fetch --tags --force # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 - name: Print Go version and environment id: vars run: | printf "Using go at: $(which go)\n" printf "Go version: $(go version)\n" printf "\n\nGo environment:\n\n" go env printf "\n\nSystem environment:\n\n" env echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT # Add "pip install" CLI tools to PATH echo ~/.local/bin >> $GITHUB_PATH # Parse semver TAG=${GITHUB_REF/refs\/tags\//} SEMVER_RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z\.-]*\)' TAG_MAJOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\1#"` TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"` TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"` TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"` echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT # Cloudsmith CLI tooling for pushing releases # See https://help.cloudsmith.io/docs/cli - name: Install Cloudsmith CLI run: pip install --upgrade cloudsmith-cli - name: Install Cosign uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # main - name: Cosign version run: cosign version - name: Install Syft uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # main - name: Syft version run: syft version - name: Install xcaddy run: | go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest xcaddy version # GoReleaser will take care of publishing those artifacts into the release - name: Run GoReleaser uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: version: latest args: release --clean --timeout 60m env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.vars.outputs.version_tag }} COSIGN_EXPERIMENTAL: 1 # Only publish on non-special tags (e.g. non-beta) # We will continue to push to Gemfury for the foreseeable future, although # Cloudsmith is probably better, to not break things for existing users of Gemfury. # See https://gemfury.com/caddy/deb:caddy - name: Publish .deb to Gemfury if: ${{ steps.vars.outputs.tag_special == '' }} env: GEMFURY_PUSH_TOKEN: ${{ secrets.GEMFURY_PUSH_TOKEN }} run: | for filename in dist/*.deb; do # armv6 and armv7 are both "armhf" so we can skip the duplicate if [[ "$filename" == *"armv6"* ]]; then echo "Skipping $filename" continue fi curl -F package=@"$filename" https://${GEMFURY_PUSH_TOKEN}:@push.fury.io/caddy/ done # Publish only special tags (unstable/beta/rc) to the "testing" repo # See https://cloudsmith.io/~caddy/repos/testing/ - name: Publish .deb to Cloudsmith (special tags) if: ${{ steps.vars.outputs.tag_special != '' }} env: CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} run: | for filename in dist/*.deb; do # armv6 and armv7 are both "armhf" so we can skip the duplicate if [[ "$filename" == *"armv6"* ]]; then echo "Skipping $filename" continue fi echo "Pushing $filename to 'testing'" cloudsmith push deb caddy/testing/any-distro/any-version $filename done # Publish stable tags to Cloudsmith to both repos, "stable" and "testing" # See https://cloudsmith.io/~caddy/repos/stable/ - name: Publish .deb to Cloudsmith (stable tags) if: ${{ steps.vars.outputs.tag_special == '' }} env: CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} run: | for filename in dist/*.deb; do # armv6 and armv7 are both "armhf" so we can skip the duplicate if [[ "$filename" == *"armv6"* ]]; then echo "Skipping $filename" continue fi echo "Pushing $filename to 'stable'" cloudsmith push deb caddy/stable/any-distro/any-version $filename echo "Pushing $filename to 'testing'" cloudsmith push deb caddy/testing/any-distro/any-version $filename done - name: Update release proposal PR if: needs.verify-tag.outputs.proposal_issue_number != '' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}'); if (prNumber) { // Get PR details to find the branch const pr = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber }); const branchName = pr.data.head.ref; // Remove in-progress label try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, name: 'release-in-progress' }); } catch (e) { console.log('Label not found:', e.message); } // Add released label await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, labels: ['released'] }); // Add final comment await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body: '## ✅ Release Published\n\nThe release has been successfully published and is now available.' }); // Close the PR if it's still open if (pr.data.state === 'open') { await github.rest.pulls.update({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, state: 'closed' }); console.log(`Closed PR #${prNumber}`); } // Delete the branch try { await github.rest.git.deleteRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `heads/${branchName}` }); console.log(`Deleted branch: ${branchName}`); } catch (e) { console.log(`Could not delete branch ${branchName}: ${e.message}`); } }