Skip to content

Latest commit

 

History

History
257 lines (209 loc) · 15.4 KB

release.md

File metadata and controls

257 lines (209 loc) · 15.4 KB

Release Reusable Workflow

Tip

Pin the uses: line to a full commit SHA with a trailing version comment. See Pinning to a SHA.

Consolidated release workflow that creates a draft release, optionally builds artifacts (GoReleaser, Docker images), publishes the release after all build jobs succeed, and then creates a GitHub Discussions announcement. This draft-first pattern supports repositories with immutable releases enabled and ensures announcements only fire for releases that publish successfully.

Choosing a variant

GitHub validates the permissions of every job in a called reusable workflow at parse time, before evaluating if conditions. Because this consolidated workflow bundles all optional jobs, callers must grant all six permission scopes even for features they do not use. If you only need some features, use one of the slimmer variant workflows instead:

Workflow Features Required permissions
release-minimal.yaml draft + tags + publish contents: write, pull-requests: read
release-goreleaser.yaml minimal + Go binaries/SBOMs contents: write, pull-requests: read
release-goreleaser-attest.yaml above + provenance/SBOM attestations + id-token: write, attestations: write
release-container.yaml minimal + Docker image push contents: write, pull-requests: read, packages: write
release-container-attest.yaml above + image attestation + id-token: write, attestations: write
release-discussion.yaml chainable announcement discussion contents: read, discussions: write (on that job only)
release.yaml (this workflow) everything, feature-flagged all six scopes below

All variants share the same draft-first core: they create the draft release and tags, run their build jobs, and only then publish, so immutable releases stay supported. To announce releases from a variant, chain release-discussion.yaml on the variant's outputs.

Triggering a release

Call the workflow on push to your default branch. This keeps pull_request_target, and the write token it hands to runs for fork pull requests, out of your repository. OSPS Baseline scanners flag that trigger under OSPS-BR-01.03.

on:
  workflow_dispatch:
  push:
    branches: [main]
concurrency:
  group: release
  cancel-in-progress: false

On push, the workflow looks up the pull request that the pushed commit merged and reads its labels. Only a push to the default branch can release. A tag push, a push to another branch, or a direct commit with no merged pull request does not. When several pull requests land in one push, only the one that produced the pushed commit counts. A workflow_dispatch run always releases.

A push run checks out, tags, and drafts notes from the pushed commit, so a commit that lands while the release runs waits for its own run. Because the release targets that commit SHA rather than the branch, push-triggered callers cannot set filter-by-commitish: true in their release-drafter config.

Callers that still trigger on pull_request_target with types: [closed] keep working. Those runs check out, tag, and draft notes from the pull request's merge commit, after verifying that the commit is on the base branch. They also target a commit SHA, so they cannot set filter-by-commitish: true either. The release variants below accept both triggers.

Inputs

- uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release.yaml@[latest SHA here] # vX.Y.Z
  permissions:
    contents: write
    pull-requests: read
    packages: write
    id-token: write
    attestations: write
    discussions: write
  with:
    # Publish the release after all jobs complete. When false, the release
    # remains a draft for manual review. Default is true.
    publish: true
    # The name of the configuration file to use
    # from the release-drafter/release-drafter GitHub Action
    release-config-name: release-drafter.yml

    # --- Optional: GoReleaser build/upload ---
    # Setting goreleaser-config-path enables the GoReleaser job
    # Path to GoReleaser config file (e.g., .goreleaser.yaml)
    goreleaser-config-path: .goreleaser.yaml
    # Path to go.mod or go.work file for Go version detection, default is go.mod
    go-version-file: go.mod
    # Only release when the 'release' label is on the PR. When true,
    # other trigger labels (breaking, feature, vuln) are ignored. Default is false.
    release-only-with-label: false
    # Read the release version from a file instead of resolving it from PR
    # labels. Path is relative to the repository root. Default is empty.
    version-file: ""
    # yq expression that selects the version inside version-file, for example
    # .version for package.json. Leave empty for a plain-text file. Default is empty.
    version-key: ""

    # --- Optional: Docker image build/push ---
    # Setting image-name enables the image build/push job
    # Image name, usually owner/repository
    image-name: ${{ github.repository }}
    # Container registry URL, default is ghcr.io
    image-registry: ghcr.io
    # Container registry username, default is github.actor
    image-registry-username: ${{ github.actor }}
    # Comma-separated list of target platforms, default is linux/amd64,linux/arm64
    image-platforms: linux/amd64,linux/arm64
    # Path to the Dockerfile, default is ./Dockerfile
    image-dockerfile: ./Dockerfile
    # Newline-separated Docker build arguments in KEY=VALUE form, passed
    # to the Docker build via docker/build-push-action build-args.
    # Default is none. Useful for baking build metadata into the image,
    # e.g. the commit SHA. Do not use build args for secrets; they can be
    # exposed in build logs and image history.
    image-build-args: |
      GIT_COMMIT=${{ github.sha }}
    # Flag to create build provenance attestations, default is false
    # Attestation is only available for public repositories. Private repos
    # will see a warning and skip attestation automatically.
    create-attestation: true
    create-discussion: true

  secrets:
    # The GitHub token to use (required)
    github-token: ${{ secrets.GITHUB_TOKEN }}
    # Container registry password (required when image-name is set)
    image-registry-password: ${{ secrets.GITHUB_TOKEN }}

    # --- Optional: GitHub Discussion announcement ---
    # Setting both discussion IDs enables the discussion creation job
    # GraphQL ID of the discussion category
    discussion-category-id: ${{ secrets.DISCUSSION_CATEGORY_ID }}
    # GraphQL ID of the repository for discussions
    discussion-repository-id: ${{ secrets.DISCUSSION_REPOSITORY_ID }}

Version from a file

Set version-file to read the release version from a file in the repository instead of resolving it from PR labels. Set version-key to a yq expression when the file is JSON, YAML, or TOML; leave it empty when the file contains only the version.

File version-file version-key
VERSION (plain text) VERSION
package.json package.json .version
Chart.yaml Chart.yaml .version
pyproject.toml pyproject.toml .project.version

Behavior:

  • The file is read from the base branch after the PR is merged, so bump the version in the same PR that is labeled release.
  • The value must be a stable semver: 1.2.3. Prereleases (1.2.3-rc.1) are rejected because the release workflows have no prerelease handling and would publish one as the latest release and move the major tag to it. Build metadata (1.2.3+build) is rejected because release-drafter's version template drops it and Docker image tags cannot contain +. A leading v and surrounding whitespace are stripped. The tag-template and name-template in the release-drafter config still apply, so v$RESOLVED_VERSION produces v1.2.3.
  • The job fails when the file is missing, the key does not match, or the value is not semver.
  • Reading a structured file needs yq on the runner. ubuntu-latest ships it.
  • Trigger labels (release, breaking, feature, vuln) still decide whether to release. Version-resolver labels (major, minor, patch) are ignored for the version.
  • Independent of version-file, the workflow now checks the tag release-drafter resolved before pushing. If that tag already exists at another commit, the job fails instead of moving it, so a merge without a version bump cannot overwrite a release. A tag that already points at the release commit is allowed so a failed run can be retried. The major tag (v1) is still moved on every release.
  • The scripts behind these checks live in scripts/ with bats tests; the workflows embed a copy that CI keeps in sync.

Outputs

  • full-tag: The full tag of the release (v1.0.0)
  • short-tag: The short tag of the release (v1)
  • body: The body of the release, to be used in the GitHub release UI
jobs:
  release:
  other_job:
    needs: release
    env:
      FULL_TAG: ${{ needs.release.outputs.full-tag }}
      SHORT_TAG: ${{ needs.release.outputs.short-tag }}
      BODY: ${{ needs.release.outputs.body }}

Jobs

The workflow runs up to six jobs:

  1. create_release - Always runs. Creates a draft release via release-drafter (listing every pull request on a first release), then creates and pushes the full and major version git tags.
  2. release_goreleaser - Runs when goreleaser-config-path is set. Builds Go binaries, uploads artifacts to the draft release, and optionally creates attestations.
  3. attest_sboms - Runs when create-attestation: true, the repository is public, and GoReleaser produced *.spdx.json files. Creates an SBOM attestation linking each archive to its corresponding SBOM via actions/attest-sbom. Fans out across archive/SBOM pairs via a matrix.
  4. release_image - Runs when image-name is set. Builds and pushes a multi-platform Docker image, and optionally creates attestations.
  5. publish_release - Runs when publish is true and all preceding jobs succeed (or are skipped). Publishes the draft release.
  6. release_discussion - Runs after publish_release succeeds and when create-discussion is set. Both discussion-category-id and discussion-repository-id secrets are required if so. Creates a GitHub Discussions announcement only after the release is successfully published.

GoReleaser Configuration

When using the goreleaser-config-path input, your GoReleaser config must disable release and changelog management since this workflow handles both via release-drafter:

release:
  disable: true

changelog:
  disable: true

Without these settings, GoReleaser will attempt to create its own GitHub release, conflicting with the draft release created by release-drafter.

SBOM generation

If your GoReleaser config includes an sboms: block that calls syft, the workflow detects it via yq and installs syft automatically before running GoReleaser. Generated *.spdx.json files are uploaded alongside the archives and included in the build provenance attestation when create-attestation: true. No additional configuration is needed beyond declaring sboms: in your GoReleaser config.

When create-attestation: true and SBOMs are produced, the attest_sboms job additionally runs actions/attest-sbom per (archive, SBOM) pair. This creates an SBOM attestation linking each archive to its corresponding .spdx.json, on top of the build provenance attestations generated by release_goreleaser. SBOM linkage requires GoReleaser to emit one SBOM per archive using the default ${artifact}.spdx.json naming pattern (or any naming that strips .spdx.json to yield the matching archive path).

Notes

  • The draft-first pattern supports repositories with immutable releases enabled. The release is created as a draft, artifacts are uploaded, and only then is it published.
  • First release: release-drafter only lists changes since the last published release, so on a repository with no published release it would write "No changes". In that case the workflow passes the repository's root commit as release-drafter's from input, so the first release lists every merged pull request in history, with your categories and templates. A pull request merged as the root commit itself is not listed. Later releases compare against the last published release as usual.
  • Artifact attestation requires a public repository. Private user-owned or organization repositories on free plans will see a warning and skip attestation automatically.
  • To get the discussion repository ID and category ID, use the GitHub CLI (gh) with the following cli and graphql query (replace OWNER and REPO with the appropriate values):
gh api graphql -f query='
  query($owner: String!, $repo: String!) {
    repository(owner: $owner, name: $repo) {
      id
      discussionCategories(first: 50) {
        nodes {
          id
          name
          slug
        }
      }
    }
  }
' -f owner='OWNER' -f repo='REPO'

Composing your own release workflow

The variant workflows are thin compositions of two building blocks, which are also callable directly if no variant fits your needs:

  • release-draft.yaml - evaluates release conditions (merged PR with trigger labels, from a push or pull_request_target event, or manual dispatch), creates the draft release via release-drafter, and pushes the full and major version tags. Outputs should-release, full-tag, short-tag, body, and release-id. Requires contents: write, pull-requests: read.
  • release-publish.yaml - publishes the draft release by release-id. Requires contents: write.

Put your own build jobs between them to attach assets to the draft before it publishes:

jobs:
  draft:
    permissions:
      contents: write # Create draft release and push tags
      pull-requests: read # Read PR labels for release-drafter
    uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release-draft.yaml@[latest SHA here] # vX.Y.Z
    with:
      release-config-name: release-drafter.yml
    secrets:
      github-token: ${{ secrets.GITHUB_TOKEN }}

  build:
    needs: draft
    if: ${{ needs.draft.outputs.full-tag != '' }}
    runs-on: ubuntu-latest
    permissions:
      contents: write # Upload release assets
    steps:
      # ... build your artifacts, then:
      - run: gh release upload "${{ needs.draft.outputs.full-tag }}" dist/*
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  publish:
    needs: [draft, build]
    if: ${{ needs.draft.outputs.release-id != '' }}
    permissions:
      contents: write # Publish draft release
    uses: github-community-projects/ospo-reusable-workflows/.github/workflows/release-publish.yaml@[latest SHA here] # vX.Y.Z
    with:
      release-id: ${{ needs.draft.outputs.release-id }}
    secrets:
      github-token: ${{ secrets.GITHUB_TOKEN }}

Reusable workflows can nest up to four levels deep. Calling a variant from your workflow uses three (your workflow, the variant, the building block), so you have one level to spare.