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 || '' }} # Values from the event, step outputs and API results are only ever # passed through env:, never interpolated as expressions into run: or # script: bodies. steps: # Validate the tag name before anything else uses it. Git allows # characters like $, ( and ; in tag names that match the v*.*.* trigger, # and the tag name ends up in shell commands (including GoReleaser hooks). - name: Validate tag name run: | if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag" exit 1 fi - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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 }} TAG: ${{ steps.vars.outputs.version_tag }} 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: $TAG" # Verify the tag is signed if ! git verify-tag -v "$TAG" 2>&1; then echo "❌ Tag verification failed!" echo "passed=false" >> $GITHUB_OUTPUT git push --delete origin "$TAG" exit 1 fi # Run it again to capture the output git verify-tag -v "$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 "$TAG" exit 1 fi echo "✅ Tag verification succeeded!" echo "passed=true" >> $GITHUB_OUTPUT echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT # Fails closed: there must be exactly one open proposal for this exact # version, with enough maintainer approvals of its current head commit. # The tag is left in place on failure, so the job can be re-run once # the proposal is approved. - name: Find related release proposal id: find_proposal uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: TAG: ${{ steps.vars.outputs.version_tag }} MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }} with: script: | const { checkProposal, REQUIRED_APPROVALS } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-proposal.js`); const { pr, targetCommit, approvers, approved } = await checkProposal({ github, context, version: process.env.TAG, maintainerLogins: process.env.MAINTAINER_LOGINS, }); console.log(`Found proposal PR #${pr.number}, for commit ${targetCommit}`); console.log(`Maintainer approvals of ${pr.head.sha}: ${approvers.join(', ') || 'none'}`); if (!approved) { core.setFailed(`Proposal #${pr.number} has ${approvers.length} maintainer approvals of its current head commit; ${REQUIRED_APPROVALS} are required`); return; } return { number: pr.number, approvals: approvers.length, approvers: approvers.map(login => `@${login}`).join(', '), proposedCommit: targetCommit }; result-encoding: json - name: Verify proposal commit env: PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }} CURRENT_COMMIT: ${{ steps.info.outputs.sha }} TAG: ${{ steps.vars.outputs.version_tag }} run: | echo "Proposed commit: $PROPOSED_COMMIT" echo "Current commit: $CURRENT_COMMIT" # Both are full SHAs, so compare them exactly if [[ ! "$PROPOSED_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then echo "❌ Proposal has no valid target commit: $PROPOSED_COMMIT" exit 1 fi if [ "$PROPOSED_COMMIT" != "$CURRENT_COMMIT" ]; then echo "❌ Commit mismatch!" echo "The tag points to commit $CURRENT_COMMIT but the proposal was for $PROPOSED_COMMIT" echo "This indicates an error in tag creation." # Delete the tag remotely git push --delete origin "$TAG" echo "Tag $TAG has been deleted" exit 1 fi echo "✅ Commit hash matches proposal" - name: Update release proposal PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RESULT: ${{ steps.find_proposal.outputs.result }} TAG: ${{ steps.info.outputs.version }} KEY_ID: ${{ steps.verify.outputs.key_id }} SHA: ${{ steps.info.outputs.sha }} with: script: | const result = JSON.parse(process.env.RESULT); // 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:** ${process.env.TAG}`, `- **Signed by key:** ${process.env.KEY_ID}`, `- **Commit:** ${process.env.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 env: PROPOSED_COMMIT: ${{ fromJson(steps.find_proposal.outputs.result).proposedCommit }} APPROVERS: ${{ fromJson(steps.find_proposal.outputs.result).approvers }} TAG: ${{ steps.info.outputs.version }} SHA: ${{ steps.info.outputs.sha }} KEY_ID: ${{ steps.verify.outputs.key_id }} run: | { echo "## Tag Verification Summary 🔐" echo "" echo "- **Tag:** $TAG" echo "- **Commit:** $SHA" echo "- **Proposed Commit:** $PROPOSED_COMMIT" echo "- **Signature:** ✅ Verified" echo "- **Signed by:** $KEY_ID" echo "- **Approvals:** ✅ Sufficient" echo "- **Approved by:** $APPROVERS" echo "" 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.27' include: # Set the minimum Go patch version for the given Go minor # Usable via ${{ matrix.GO_SEMVER }} - go: '1.27' GO_SEMVER: '~1.27.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: # Same check as in verify-tag: the tag name ends up in GoReleaser's # shell hooks via $TAG. - name: Validate tag name run: | if [[ ! "$GITHUB_REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "❌ Refusing to release $GITHUB_REF: not a vMAJOR.MINOR.PATCH[-PRERELEASE] tag" exit 1 fi - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3 with: egress-policy: audit - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Install Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.GO_SEMVER }} check-latest: true # Force fetch upstream tags -- because 65 minutes # tl;dr: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Cosign version run: cosign version - name: Install Syft uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # main - name: Syft version run: syft version - name: Install xcaddy run: | go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest xcaddy version # Generate the draft's notes in the same format as GitHub's "Generate # release notes" button, compared against the previous *published* # release by version (not the previous tag, which may be an abandoned # release attempt), and without PRs that already shipped in it as # backports. GoReleaser's own changelog can do neither. - name: Generate release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.vars.outputs.version_tag }} NOTES_FILE: ${{ runner.temp }}/release-notes.md run: | PREVIOUS_RELEASE=$(.github/scripts/previous-release.sh "$TAG") echo "Previous published release: ${PREVIOUS_RELEASE:-none}" .github/scripts/release-notes.sh "$TAG" "$TAG" "$PREVIOUS_RELEASE" > "$NOTES_FILE" cat "$NOTES_FILE" # GoReleaser will take care of publishing those artifacts into the release - name: Run GoReleaser uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 with: version: latest args: release --clean --timeout 60m --release-notes ${{ runner.temp }}/release-notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.vars.outputs.version_tag }} # 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PROPOSAL_NUMBER: ${{ needs.verify-tag.outputs.proposal_issue_number }} with: script: | const prNumber = parseInt(process.env.PROPOSAL_NUMBER, 10); 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}`); } }