caddyserver/caddy

9 workflows · maturity 50% · 8 patterns · GitHub ↗

Security 68.33/100

Practices

✓ Matrix✓ Permissions✓ Security scan○ AI review○ Cache○ Concurrency○ Reusable workflows

Detected patterns

Security dimensions

permissions
25
security scan
8.3
supply chain
20
secret handling
0
harden runner
15

Tools: github/codeql-action/upload-sarif, ossf/scorecard-action

Workflows (9)

ai perms .github/workflows/ai.yml
Triggers
issues, issue_comment, pull_request_review_comment
Runs on
ubuntu-latest
Jobs
spam-detection
Actions
github/ai-moderator
View raw YAML
name: AI Moderator
permissions: read-all
on:
  issues:
    types: [opened]
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
jobs:
  spam-detection:
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write
      models: read
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
      - uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          spam-label: 'spam'
          ai-label: 'ai-generated'
          minimize-detected-comments: true
          # Built-in prompt configuration (all enabled by default)
          enable-spam-detection: true
          enable-link-spam-detection: true
          enable-ai-detection: true
          # custom-prompt-path: '.github/prompts/my-custom.prompt.yml'  # Optional
auto-release-pr perms .github/workflows/auto-release-pr.yml
Triggers
pull_request_review, pull_request
Runs on
ubuntu-latest, ubuntu-latest
Jobs
check-approvals, handle-pr-closed
View raw YAML
name: Release Proposal Approval Tracker

on:
  pull_request_review:
    types: [submitted, dismissed]
  pull_request:
    types: [labeled, unlabeled, synchronize, closed]

permissions:
  contents: read
  pull-requests: write
  issues: write

jobs:
  check-approvals:
    name: Track Maintainer Approvals
    runs-on: ubuntu-latest
    # Only run on PRs with release-proposal label
    if: contains(github.event.pull_request.labels.*.name, 'release-proposal') && github.event.pull_request.state == 'open'
    
    steps:
      - name: Check approvals and update PR
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        env:
          MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
        with:
          script: |
            const pr = context.payload.pull_request;
            
            // Extract version from PR title (e.g., "Release Proposal: v1.2.3")
            const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/);
            const commitMatch = pr.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/);
            
            if (!versionMatch || !commitMatch) {
              console.log('Could not extract version from title or commit from body');
              return;
            }
            
            const version = versionMatch[1];
            const targetCommit = commitMatch[1];
            
            console.log(`Version: ${version}, Target Commit: ${targetCommit}`);
            
            // Get all reviews
            const reviews = await github.rest.pulls.listReviews({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: pr.number
            });
            
            // Get list of maintainers
            const maintainerLoginsRaw = process.env.MAINTAINER_LOGINS || '';
            const maintainerLogins = maintainerLoginsRaw
              .split(/[,;]/)
              .map(login => login.trim())
              .filter(login => login.length > 0);
            
            console.log(`Maintainer logins: ${maintainerLogins.join(', ')}`);
            
            // Get the latest review from each user
            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;
              }
            });
            
            // Count approvals from maintainers
            const maintainerApprovals = Object.entries(latestReviewsByUser)
              .filter(([username, review]) => 
                maintainerLogins.includes(username) && 
                review.state === 'APPROVED'
              )
              .map(([username, review]) => username);
            
            const approvalCount = maintainerApprovals.length;
            console.log(`Found ${approvalCount} maintainer approvals from: ${maintainerApprovals.join(', ')}`);
            
            // Get current labels
            const currentLabels = pr.labels.map(label => label.name);
            const hasApprovedLabel = currentLabels.includes('approved');
            const hasAwaitingApprovalLabel = currentLabels.includes('awaiting-approval');
            
            if (approvalCount >= 2 && !hasApprovedLabel) {
              console.log('✅ Quorum reached! Updating PR...');
              
              // Remove awaiting-approval label if present
              if (hasAwaitingApprovalLabel) {
                await github.rest.issues.removeLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pr.number,
                  name: 'awaiting-approval'
                }).catch(e => console.log('Label not found:', e.message));
              }
              
              // Add approved label
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                labels: ['approved']
              });
              
              // Add comment with tagging instructions
              const approversList = maintainerApprovals.map(u => `@${u}`).join(', ');
              const commentBody = [
                '## ✅ Approval Quorum Reached',
                '',
                `This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`,
                '',
                '### Tagging Instructions',
                '',
                'A maintainer should now create and push the signed tag:',
                '',
                '```bash',
                `git checkout ${targetCommit}`,
                `git tag -s ${version} -m "Release ${version}"`,
                `git push origin ${version}`,
                `git checkout -`,
                '```',
                '',
                'The release workflow will automatically start when the tag is pushed.'
              ].join('\n');
              
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                body: commentBody
              });
              
              console.log('Posted tagging instructions');
            } else if (approvalCount < 2 && hasApprovedLabel) {
              console.log('⚠️  Approval count dropped below quorum, removing approved label');
              
              // Remove approved label
              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                name: 'approved'
              }).catch(e => console.log('Label not found:', e.message));
              
              // Add awaiting-approval label
              if (!hasAwaitingApprovalLabel) {
                await github.rest.issues.addLabels({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pr.number,
                  labels: ['awaiting-approval']
                });
              }
            } else {
              console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`);
            }

  handle-pr-closed:
    name: Handle PR Closed Without Tag
    runs-on: ubuntu-latest
    if: |
      contains(github.event.pull_request.labels.*.name, 'release-proposal') &&
      github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released')
    
    steps:
      - name: Add cancelled label and comment
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            const pr = context.payload.pull_request;
            
            // Check if the release-in-progress label is present
            const hasReleaseInProgress = pr.labels.some(label => label.name === 'release-in-progress');
            
            if (hasReleaseInProgress) {
              // PR was closed while release was in progress - this is unusual
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                body: '⚠️ **Warning:** This PR was closed while a release was in progress. This may indicate an error. Please verify the release status.'
              });
            } else {
              // PR was closed before tag was created - this is normal cancellation
              const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/);
              const version = versionMatch ? versionMatch[1] : 'unknown';
              
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                body: `## 🚫 Release Proposal Cancelled\n\nThis release proposal for ${version} was closed without creating the tag.\n\nIf you want to proceed with this release later, you can create a new release proposal.`
              });
            }
            
            // Add cancelled label
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
              labels: ['cancelled']
            });
            
            // Remove other workflow labels if present
            const labelsToRemove = ['awaiting-approval', 'approved', 'release-in-progress'];
            for (const label of labelsToRemove) {
              try {
                await github.rest.issues.removeLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: pr.number,
                  name: label
                });
              } catch (e) {
                console.log(`Label ${label} not found or already removed`);
              }
            }
            
            console.log('Added cancelled label and cleaned up workflow labels');
            
ci matrix perms .github/workflows/ci.yml
Triggers
push, pull_request
Runs on
${{ matrix.OS_LABEL }}, ubuntu-latest, ubuntu-latest
Jobs
test, s390x-test, goreleaser-check
Matrix
go, include, include.CADDY_BIN_PATH, include.GO_SEMVER, include.OS_LABEL, include.SUCCESS, include.go, include.os, os→ ./cmd/caddy/caddy, ./cmd/caddy/caddy.exe, 0, 1.26, True, linux, mac, macos-14, ubuntu-latest, windows, windows-latest, ~1.26.0
Actions
step-security/harden-runner, step-security/harden-runner, step-security/harden-runner, goreleaser/goreleaser-action, goreleaser/goreleaser-action
Commands
  • 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 printf "Git version: $(git version)\n\n" # Calculate the short SHA1 hash of the git commit echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
  • go get -v -t -d ./... # mkdir test-results
  • go build -trimpath -ldflags="-w -s" -v
  • ./caddy start ./caddy stop
  • # (go test -v -coverprofile=cover-profile.out -race ./... 2>&1) > test-results/test-result.out go test -v -coverprofile="cover-profile.out" -short -race ./... # echo "status=$?" >> $GITHUB_OUTPUT
  • set +e mkdir -p ~/.ssh && echo -e "${SSH_KEY//_/\\n}" > ~/.ssh/id_ecdsa && chmod og-rwx ~/.ssh/id_ecdsa # short sha is enough? short_sha=$(git rev-parse --short HEAD) # To shorten the following lines ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" ssh_host="$CI_USER@ci-s390x.caddyserver.com" # The environment is fresh, so there's no point in keeping accepting and adding the key. rsync -arz -e "ssh $ssh_opts" --progress --delete --exclude '.git' . "$ssh_host":/var/tmp/"$short_sha" ssh $ssh_opts -t "$ssh_host" bash <<EOF cd /var/tmp/$short_sha go version go env printf "\n\n" retries=3 exit_code=0 while ((retries > 0)); do CGO_ENABLED=0 go test -p 1 -v ./... exit_code=$? if ((exit_code == 0)); then break fi echo "\n\nTest failed: \$exit_code, retrying..." ((retries--)) done echo "Remote exit code: \$exit_code" exit \$exit_code EOF test_result=$? # There's no need leaving the files around ssh $ssh_opts "$ssh_host" "rm -rf /var/tmp/'$short_sha'" echo "Test exit code: $test_result" exit $test_result
  • go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest xcaddy version
View raw YAML
# Used as inspiration: https://github.com/mvdan/github-actions-golang

name: Tests

on:
  push:
    branches:
      - master
      - 2.*
  pull_request:
    branches:
      - master
      - 2.*

env:
  GOFLAGS: '-tags=nobadger,nomysql,nopgx'
  # https://github.com/actions/setup-go/issues/491
  GOTOOLCHAIN: local

permissions:
  contents: read

jobs:
  test:
    strategy:
      # Default is true, cancels jobs for other platforms in the matrix if one fails
      fail-fast: false
      matrix:
        os:
          - linux
          - mac
          - windows
        go:
          - '1.26'

        include:
        # Set the minimum Go patch version for the given Go minor
        # Usable via ${{ matrix.GO_SEMVER }}
        - go: '1.26'
          GO_SEMVER: '~1.26.0'

        # Set some variables per OS, usable via ${{ matrix.VAR }}
        # OS_LABEL: the VM label from GitHub Actions (see https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories)
        # CADDY_BIN_PATH: the path to the compiled Caddy binary, for artifact publishing
        # SUCCESS: the typical value for $? per OS (Windows/pwsh returns 'True')
        - os: linux
          OS_LABEL: ubuntu-latest
          CADDY_BIN_PATH: ./cmd/caddy/caddy
          SUCCESS: 0

        - os: mac
          OS_LABEL: macos-14
          CADDY_BIN_PATH: ./cmd/caddy/caddy
          SUCCESS: 0

        - os: windows
          OS_LABEL: windows-latest
          CADDY_BIN_PATH: ./cmd/caddy/caddy.exe
          SUCCESS: 'True'

    runs-on: ${{ matrix.OS_LABEL }}
    permissions:
      contents: read
      pull-requests: read
      actions: write # to allow uploading artifacts and cache
    steps:
    - name: Harden the runner (Audit all outbound calls)
      uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
      with:
        egress-policy: audit

    - name: Checkout code
      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

    - name: Install Go
      uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
      with:
        go-version: ${{ matrix.GO_SEMVER }}
        check-latest: true

    # These tools would be useful if we later decide to reinvestigate
    # publishing test/coverage reports to some tool for easier consumption
    # - name: Install test and coverage analysis tools
    #   run: |
    #     go get github.com/axw/gocov/gocov
    #     go get github.com/AlekSi/gocov-xml
    #     go get -u github.com/jstemmer/go-junit-report
    #     echo "$(go env GOPATH)/bin" >> $GITHUB_PATH

    - name: Print Go version and environment
      id: vars
      shell: bash
      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
        printf "Git version: $(git version)\n\n"
        # Calculate the short SHA1 hash of the git commit
        echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT

    - name: Get dependencies
      run: |
        go get -v -t -d ./...
        # mkdir test-results

    - name: Build Caddy
      working-directory: ./cmd/caddy
      env:
        CGO_ENABLED: 0
      run: |
        go build -trimpath -ldflags="-w -s" -v

    - name: Smoke test Caddy
      working-directory: ./cmd/caddy
      run: |
        ./caddy start
        ./caddy stop

    - name: Publish Build Artifact
      uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
      with:
        name: caddy_${{ runner.os }}_go${{ matrix.go }}_${{ steps.vars.outputs.short_sha }}
        path: ${{ matrix.CADDY_BIN_PATH }}
        compression-level: 0

    # Commented bits below were useful to allow the job to continue
    # even if the tests fail, so we can publish the report separately
    # For info about set-output, see https://stackoverflow.com/questions/57850553/github-actions-check-steps-status
    - name: Run tests
      # id: step_test
      # continue-on-error: true
      run: |
        # (go test -v -coverprofile=cover-profile.out -race ./... 2>&1) > test-results/test-result.out
        go test -v -coverprofile="cover-profile.out" -short -race ./...
        # echo "status=$?" >> $GITHUB_OUTPUT

    # Relevant step if we reinvestigate publishing test/coverage reports
    # - name: Prepare coverage reports
    #   run: |
    #     mkdir coverage
    #     gocov convert cover-profile.out > coverage/coverage.json
    #     # Because Windows doesn't work with input redirection like *nix, but output redirection works.
    #     (cat ./coverage/coverage.json | gocov-xml) > coverage/coverage.xml

    # To return the correct result even though we set 'continue-on-error: true'
    # - name: Coerce correct build result
    #   if: matrix.os != 'windows' && steps.step_test.outputs.status != ${{ matrix.SUCCESS }}
    #   run: |
    #     echo "step_test ${{ steps.step_test.outputs.status }}\n"
    #     exit 1

  s390x-test:
    name: test (s390x on IBM Z)
    permissions:
      contents: read
      pull-requests: read
    runs-on: ubuntu-latest
    if: github.event.pull_request.head.repo.full_name == 'caddyserver/caddy' && github.actor != 'dependabot[bot]'
    continue-on-error: true  # August 2020: s390x VM is down due to weather and power issues
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit
          allowed-endpoints: ci-s390x.caddyserver.com:22

      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Run Tests
        run: |
          set +e
          mkdir -p ~/.ssh && echo -e "${SSH_KEY//_/\\n}" > ~/.ssh/id_ecdsa && chmod og-rwx ~/.ssh/id_ecdsa

          # short sha is enough?
          short_sha=$(git rev-parse --short HEAD)

          # To shorten the following lines
          ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
          ssh_host="$CI_USER@ci-s390x.caddyserver.com"

          # The environment is fresh, so there's no point in keeping accepting and adding the key.
          rsync -arz -e "ssh $ssh_opts" --progress --delete --exclude '.git' . "$ssh_host":/var/tmp/"$short_sha"
          ssh $ssh_opts -t "$ssh_host" bash <<EOF
          cd /var/tmp/$short_sha
          go version
          go env
          printf "\n\n"
          retries=3
          exit_code=0
          while ((retries > 0)); do
            CGO_ENABLED=0 go test -p 1 -v ./...
            exit_code=$?
            if ((exit_code == 0)); then
              break
            fi
            echo "\n\nTest failed: \$exit_code, retrying..."
            ((retries--))
          done
          echo "Remote exit code: \$exit_code"
          exit \$exit_code
          EOF
          test_result=$?

          # There's no need leaving the files around
          ssh $ssh_opts "$ssh_host" "rm -rf /var/tmp/'$short_sha'"

          echo "Test exit code: $test_result"
          exit $test_result
        env:
          SSH_KEY: ${{ secrets.S390X_SSH_KEY }}
          CI_USER: ${{ secrets.CI_USER }}

  goreleaser-check:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    if: github.event.pull_request.head.repo.full_name == 'caddyserver/caddy' && github.actor != 'dependabot[bot]'
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit

      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      
      - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
        with:
          version: latest
          args: check
      - name: Install Go
        uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
        with:
          go-version: "~1.26"
          check-latest: true
      - name: Install xcaddy
        run: |
          go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
          xcaddy version
      - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
        with:
          version: latest
          args: build --single-target --snapshot
        env:
          TAG: ${{ github.head_ref || github.ref_name }}
cross-build matrix perms .github/workflows/cross-build.yml
Triggers
push, pull_request
Runs on
ubuntu-latest
Jobs
build
Matrix
go, goos, include, include.GO_SEMVER, include.go→ 1.26, aix, darwin, dragonfly, freebsd, illumos, linux, netbsd, openbsd, solaris, windows, ~1.26.0
Actions
step-security/harden-runner
Commands
  • 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
  • go build -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null
View raw YAML
name: Cross-Build

on:
  push:
    branches:
      - master
      - 2.*
  pull_request:
    branches:
      - master
      - 2.*

env:
  GOFLAGS: '-tags=nobadger,nomysql,nopgx'
  CGO_ENABLED: '0'
  # https://github.com/actions/setup-go/issues/491
  GOTOOLCHAIN: local

permissions:
  contents: read

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        goos:
          - 'aix'
          - 'linux'
          - 'solaris'
          - 'illumos'
          - 'dragonfly'
          - 'freebsd'
          - 'openbsd'
          - 'windows'
          - 'darwin'
          - 'netbsd'
        go:
          - '1.26'

        include:
        # Set the minimum Go patch version for the given Go minor
        # Usable via ${{ matrix.GO_SEMVER }}
        - go: '1.26'
          GO_SEMVER: '~1.26.0'

    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: read
    continue-on-error: true
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit

      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install Go
        uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
        with:
          go-version: ${{ matrix.GO_SEMVER }}
          check-latest: true

      - 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

      - name: Run Build
        env:
          GOOS: ${{ matrix.goos }}
          GOARCH: ${{ matrix.goos == 'aix' && 'ppc64' || 'amd64' }}
        shell: bash
        continue-on-error: true
        working-directory: ./cmd/caddy
        run: go build -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null
lint matrix perms .github/workflows/lint.yml
Triggers
push, pull_request
Runs on
${{ matrix.OS_LABEL }}, ubuntu-latest, ubuntu-latest
Jobs
golangci, govulncheck, dependency-review
Matrix
include, include.OS_LABEL, include.os, os→ linux, mac, macos-14, ubuntu-latest, windows, windows-latest
Actions
step-security/harden-runner, golangci/golangci-lint-action, step-security/harden-runner, golang/govulncheck-action, step-security/harden-runner, actions/dependency-review-action
View raw YAML
name: Lint

on:
  push:
    branches:
      - master
      - 2.*
  pull_request:
    branches:
      - master
      - 2.*

permissions:
  contents: read

env:
  # https://github.com/actions/setup-go/issues/491
  GOTOOLCHAIN: local

jobs:
  # From https://github.com/golangci/golangci-lint-action
  golangci:
    permissions:
      contents: read # for actions/checkout to fetch code
      pull-requests: read # for golangci/golangci-lint-action to fetch pull requests
    name: lint
    strategy:
      matrix:
        os:
          - linux
          - mac
          - windows

        include:
        - os: linux
          OS_LABEL: ubuntu-latest

        - os: mac
          OS_LABEL: macos-14

        - os: windows
          OS_LABEL: windows-latest

    runs-on: ${{ matrix.OS_LABEL }}

    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit

      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
        with:
          go-version: '~1.26'
          check-latest: true

      - name: golangci-lint
        uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
        with:
          version: latest

          # Windows times out frequently after about 5m50s if we don't set a longer timeout.
          args: --timeout 10m

          # Optional: show only new issues if it's a pull request. The default value is `false`.
          # only-new-issues: true

  govulncheck:
    permissions:
      contents: read
      pull-requests: read
    runs-on: ubuntu-latest
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit

      - name: govulncheck
        uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
        with:
          go-version-input: '~1.26.0'
          check-latest: true
  
  dependency-review:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit

      - name: 'Checkout Repository'
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: 'Dependency Review'
        uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
        with:
          comment-summary-in-pr: on-failure
          # https://github.com/actions/dependency-review-action/issues/430#issuecomment-1468975566
          base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
          head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
release matrix perms .github/workflows/release.yml
Triggers
push
Runs on
ubuntu-latest, ${{ matrix.os }}
Jobs
verify-tag, release
Matrix
go, include, include.GO_SEMVER, include.go, os→ 1.26, ubuntu-latest, ~1.26.0
Actions
step-security/harden-runner, sigstore/cosign-installer, anchore/sbom-action/download-syft, goreleaser/goreleaser-action
Commands
  • git fetch --tags --force
  • echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
  • 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
  • # 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
  • 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"
  • 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
  • git fetch --tags --force
  • 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
View raw YAML
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        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.26'

        include:
        # Set the minimum Go patch version for the given Go minor
        # Usable via ${{ matrix.GO_SEMVER }}
        - go: '1.26'
          GO_SEMVER: '~1.26.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@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
      with:
        egress-policy: audit

    - name: Checkout code
      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      with:
        fetch-depth: 0

    - name: Install Go
      uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
      with:
        go-version: ${{ matrix.GO_SEMVER }}
        check-latest: true

    # Force fetch upstream tags -- because 65 minutes
    # tl;dr: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@17ae1740179002c89186b61233e0f892c3118b11 # 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@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.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}`);
            }
          }
release-proposal perms .github/workflows/release-proposal.yml
Triggers
workflow_dispatch
Runs on
ubuntu-latest
Jobs
create-proposal
Actions
step-security/harden-runner
Commands
  • # Trim whitespace from inputs VERSION=$(echo "${{ inputs.version }}" | xargs) COMMIT_HASH=$(echo "${{ inputs.commit_hash }}" | xargs) echo "version=$VERSION" >> $GITHUB_OUTPUT echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT # Validate version format if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)" exit 1 fi # Validate commit hash format if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then echo "Error: Commit hash must be a valid SHA (7-40 characters)" exit 1 fi # Check if commit exists if ! git cat-file -e "$COMMIT_HASH"; then echo "Error: Commit $COMMIT_HASH does not exist" exit 1 fi
  • if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists" exit 1 fi
  • VERSION="${{ steps.inputs.outputs.version }}" COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}" # Create a new branch for the release proposal BRANCH_NAME="release_proposal-$VERSION" git checkout -b "$BRANCH_NAME" # Calculate how many commits behind HEAD COMMITS_BEHIND=$(git rev-list --count ${COMMIT_HASH}..HEAD) if [ "$COMMITS_BEHIND" -eq 0 ]; then BEHIND_INFO="This is the latest commit (HEAD)" else BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**" fi echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT echo "behind_info=$BEHIND_INFO" >> $GITHUB_OUTPUT # Get the last tag LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -z "$LAST_TAG" ]; then echo "No previous tag found, generating full changelog" COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH") else echo "Generating changelog since $LAST_TAG" COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH") fi # Store changelog for PR body CLEANSED_COMMITS=$(echo "$COMMITS" | sed 's/`/\\`/g') echo "changelog<<EOF" >> $GITHUB_OUTPUT echo "$CLEANSED_COMMITS" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT # Create empty commit for the PR git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit --allow-empty -m "Release proposal for $VERSION" # Push the branch git push origin "$BRANCH_NAME" echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
  • echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY
View raw YAML
name: Release Proposal

# This workflow creates a release proposal as a PR that requires approval from maintainers
# Triggered manually by maintainers when ready to prepare a release
on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to release (e.g., v2.8.0)'
        required: true
        type: string
      commit_hash:
        description: 'Commit hash to release from'
        required: true
        type: string

permissions:
  contents: read

jobs:
  create-proposal:
    name: Create Release Proposal
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
    
    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit
      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0

      - name: Trim and validate inputs
        id: inputs
        run: |
          # Trim whitespace from inputs
          VERSION=$(echo "${{ inputs.version }}" | xargs)
          COMMIT_HASH=$(echo "${{ inputs.commit_hash }}" | xargs)
          
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT
          
          # Validate version format
          if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
            echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)"
            exit 1
          fi
          
          # Validate commit hash format
          if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then
            echo "Error: Commit hash must be a valid SHA (7-40 characters)"
            exit 1
          fi
          
          # Check if commit exists
          if ! git cat-file -e "$COMMIT_HASH"; then
            echo "Error: Commit $COMMIT_HASH does not exist"
            exit 1
          fi

      - name: Check if tag already exists
        run: |
          if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then
            echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists"
            exit 1
          fi

      - name: Check for existing proposal PR
        id: check_existing
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            const version = '${{ steps.inputs.outputs.version }}';
            
            // Search for existing open PRs with release-proposal label that match this version
            const openPRs = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'open',
              sort: 'updated',
              direction: 'desc'
            });
            
            const existingOpenPR = openPRs.data.find(pr => 
              pr.title.includes(version) && 
              pr.labels.some(label => label.name === 'release-proposal')
            );
            
            if (existingOpenPR) {
              const hasReleased = existingOpenPR.labels.some(label => label.name === 'released');
              const hasReleaseInProgress = existingOpenPR.labels.some(label => label.name === 'release-in-progress');
              
              if (hasReleased || hasReleaseInProgress) {
                core.setFailed(`A release for ${version} is already in progress or completed: ${existingOpenPR.html_url}`);
              } else {
                core.setFailed(`An open release proposal already exists for ${version}: ${existingOpenPR.html_url}\n\nPlease use the existing PR or close it first.`);
              }
              return;
            }
            
            // Check for closed PRs with this version that were cancelled
            const closedPRs = await github.rest.pulls.list({
              owner: context.repo.owner,
              repo: context.repo.repo,
              state: 'closed',
              sort: 'updated',
              direction: 'desc'
            });
            
            const cancelledPR = closedPRs.data.find(pr => 
              pr.title.includes(version) && 
              pr.labels.some(label => label.name === 'release-proposal') &&
              pr.labels.some(label => label.name === 'cancelled')
            );
            
            if (cancelledPR) {
              console.log(`Found previously cancelled proposal for ${version}: ${cancelledPR.html_url}`);
              console.log('Creating new proposal to replace cancelled one...');
            } else {
              console.log(`No existing proposal found for ${version}, proceeding...`);
            }

      - name: Generate changelog and create branch
        id: setup
        run: |
          VERSION="${{ steps.inputs.outputs.version }}"
          COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}"
          
          # Create a new branch for the release proposal
          BRANCH_NAME="release_proposal-$VERSION"
          git checkout -b "$BRANCH_NAME"
          
          # Calculate how many commits behind HEAD
          COMMITS_BEHIND=$(git rev-list --count ${COMMIT_HASH}..HEAD)
          
          if [ "$COMMITS_BEHIND" -eq 0 ]; then
            BEHIND_INFO="This is the latest commit (HEAD)"
          else
            BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**"
          fi
          
          echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT
          echo "behind_info=$BEHIND_INFO" >> $GITHUB_OUTPUT
          
          # Get the last tag
          LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
          
          if [ -z "$LAST_TAG" ]; then
            echo "No previous tag found, generating full changelog"
            COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH")
          else
            echo "Generating changelog since $LAST_TAG"
            COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH")
          fi
          
          # Store changelog for PR body
          CLEANSED_COMMITS=$(echo "$COMMITS" | sed 's/`/\\`/g')
          echo "changelog<<EOF" >> $GITHUB_OUTPUT
          echo "$CLEANSED_COMMITS" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
          
          # Create empty commit for the PR
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git commit --allow-empty -m "Release proposal for $VERSION"
          
          # Push the branch
          git push origin "$BRANCH_NAME"
          
          echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT

      - name: Create release proposal PR
        id: create_pr
        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
        with:
          script: |
            const changelog = `${{ steps.setup.outputs.changelog }}`;
            
            const pr = await github.rest.pulls.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: `Release Proposal: ${{ steps.inputs.outputs.version }}`,
              head: '${{ steps.setup.outputs.branch_name }}',
              base: 'master',
              body: `## Release Proposal: ${{ steps.inputs.outputs.version }}
            
            **Target Commit:** \`${{ steps.inputs.outputs.commit_hash }}\`
            **Requested by:** @${{ github.actor }}
            **Commit Status:** ${{ steps.setup.outputs.behind_info }}
            
            This PR proposes creating release tag \`${{ steps.inputs.outputs.version }}\` at commit \`${{ steps.inputs.outputs.commit_hash }}\`.
            
            ### Approval Process
            
            This PR requires **approval from 2+ maintainers** before the tag can be created.
            
            ### What happens next?
            
            1. Maintainers review this proposal
            2. When 2+ maintainer approvals are received, an automated workflow will post tagging instructions
            3. A maintainer manually creates and pushes the signed tag
            4. The release workflow is triggered automatically by the tag push
            5. Upon release completion, this PR is closed and the branch is deleted
            
            ### Changes Since Last Release
            
            ${changelog}
            
            ### Release Checklist
            
            - [ ] All tests pass
            - [ ] Security review completed
            - [ ] Documentation updated
            - [ ] Breaking changes documented
            
            ---
            
            **Note:** Tag creation is manual and requires a signed tag from a maintainer.`,
              draft: true
            });
            
            // Add labels
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.data.number,
              labels: ['release-proposal', 'awaiting-approval']
            });
            
            console.log(`Created PR: ${pr.data.html_url}`);
            
            return { number: pr.data.number, url: pr.data.html_url };
          result-encoding: json

      - name: Post summary
        run: |
          echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY
          echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY
          echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY
release_published matrix perms .github/workflows/release_published.yml
Triggers
release
Runs on
${{ matrix.os }}
Jobs
release
Matrix
os→ ubuntu-latest
Actions
step-security/harden-runner, peter-evans/repository-dispatch, peter-evans/repository-dispatch
View raw YAML
name: Release Published

# Event payload: https://developer.github.com/webhooks/event-payloads/#release
on:
  release:
    types: [published]

permissions:
  contents: read

jobs:
  release:
    name: Release Published
    strategy:
      matrix:
        os: 
          - ubuntu-latest
    runs-on: ${{ matrix.os }}
    permissions:
      contents: read
      pull-requests: read
      actions: write
    steps:

    # See https://github.com/peter-evans/repository-dispatch
    - name: Harden the runner (Audit all outbound calls)
      uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
      with:
        egress-policy: audit

    - name: Trigger event on caddyserver/dist
      uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
      with:
        token: ${{ secrets.REPO_DISPATCH_TOKEN }}
        repository: caddyserver/dist
        event-type: release-tagged
        client-payload: '{"tag": "${{ github.event.release.tag_name }}"}'

    - name: Trigger event on caddyserver/caddy-docker
      uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
      with:
        token: ${{ secrets.REPO_DISPATCH_TOKEN }}
        repository: caddyserver/caddy-docker
        event-type: release-tagged
        client-payload: '{"tag": "${{ github.event.release.tag_name }}"}'

scorecard perms security .github/workflows/scorecard.yml
Triggers
branch_protection_rule, schedule, push, pull_request
Runs on
ubuntu-latest
Jobs
analysis
Actions
step-security/harden-runner, ossf/scorecard-action, github/codeql-action/upload-sarif
View raw YAML
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.

name: OpenSSF Scorecard supply-chain security
on:
  # For Branch-Protection check. Only the default branch is supported. See
  # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
  branch_protection_rule:
  # To guarantee Maintained check is occasionally updated. See
  # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
  schedule:
    - cron: '20 2 * * 5'
  push:
    branches: [ "master", "2.*" ]
  pull_request:
    branches: [ "master", "2.*" ]


# Declare default permissions as read only.
permissions: read-all

jobs:
  analysis:
    name: Scorecard analysis
    runs-on: ubuntu-latest
    # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
    if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
    permissions:
      # Needed to upload the results to code-scanning dashboard.
      security-events: write
      # Needed to publish results and get a badge (see publish_results below).
      id-token: write
      # Uncomment the permissions below if installing in a private repository.
      # contents: read
      # actions: read

    steps:
      - name: Harden the runner (Audit all outbound calls)
        uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
        with:
          egress-policy: audit

      - name: "Checkout code"
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          persist-credentials: false

      - name: "Run analysis"
        uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
        with:
          results_file: results.sarif
          results_format: sarif
          # (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
          # - you want to enable the Branch-Protection check on a *public* repository, or
          # - you are installing Scorecard on a *private* repository
          # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
          # repo_token: ${{ secrets.SCORECARD_TOKEN }}

          # Public repositories:
          #   - Publish results to OpenSSF REST API for easy access by consumers
          #   - Allows the repository to include the Scorecard badge.
          #   - See https://github.com/ossf/scorecard-action#publishing-results.
          # For private repositories:
          #   - `publish_results` will always be set to `false`, regardless
          #     of the value entered here.
          publish_results: true

          # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
          # file_mode: git

      # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
      # format to the repository Actions tab.
      - name: "Upload artifact"
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: SARIF file
          path: results.sarif
          retention-days: 5

      # Upload the results to GitHub's code scanning dashboard (optional).
      # Commenting out will disable upload of results to your repo's Code Scanning dashboard
      - name: "Upload to code-scanning"
        uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5
        with:
          sarif_file: results.sarif