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.
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.
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: falseOn 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.
- 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 }}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 leadingvand surrounding whitespace are stripped. Thetag-templateandname-templatein the release-drafter config still apply, sov$RESOLVED_VERSIONproducesv1.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
yqon the runner.ubuntu-latestships 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.
- 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 }}The workflow runs up to six jobs:
- 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.
- release_goreleaser - Runs when
goreleaser-config-pathis set. Builds Go binaries, uploads artifacts to the draft release, and optionally creates attestations. - attest_sboms - Runs when
create-attestation: true, the repository is public, and GoReleaser produced*.spdx.jsonfiles. Creates an SBOM attestation linking each archive to its corresponding SBOM viaactions/attest-sbom. Fans out across archive/SBOM pairs via a matrix. - release_image - Runs when
image-nameis set. Builds and pushes a multi-platform Docker image, and optionally creates attestations. - publish_release - Runs when
publishis true and all preceding jobs succeed (or are skipped). Publishes the draft release. - release_discussion - Runs after
publish_releasesucceeds and whencreate-discussionis set. Bothdiscussion-category-idanddiscussion-repository-idsecrets are required if so. Creates a GitHub Discussions announcement only after the release is successfully published.
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: trueWithout these settings, GoReleaser will attempt to create its own GitHub release, conflicting with the draft release created by release-drafter.
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).
- 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
frominput, 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
OWNERandREPOwith the appropriate values):- Our former suggested way to get this information, The GraphQL API Explorer](https://docs.github.com/en/graphql/guides/using-graphql-clients), was removed on November 11, 2025.
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'
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
pushorpull_request_targetevent, or manual dispatch), creates the draft release via release-drafter, and pushes the full and major version tags. Outputsshould-release,full-tag,short-tag,body, andrelease-id. Requirescontents: write,pull-requests: read. - release-publish.yaml - publishes the draft release by
release-id. Requirescontents: 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.