A GitHub Actions job with contents: write can turn one compromised build script into a public release, a moved tag, or an overwritten release asset. The safer design is not “add a release action”; it is making a protected v* tag the narrow gate that receives write access only after the build has passed.
A practical release pipeline has three separate decisions: who may create a version tag, which commit that tag identifies, and which job may publish a GitHub Release. Treating those as one permission decision is how a routine packaging workflow ends up with repository-wide write authority.
Use the tag as the release contract, not a branch name
For a typical application repository, use version tags such as v1.8.0, v1.8.1, and v2.0.0-rc.1 as the release trigger. A branch like main changes continually; a tag should identify one intended commit. The workflow should publish a release for the tag that triggered it, rather than calculating a release version from the current state of main.
That distinction prevents a subtle failure: a release job starts from main, tests commit A, and publishes binaries built after commit B has landed. The visible release says v1.8.0, but the source reference and binary no longer describe the same code.
Start with a tag-triggered workflow:
name: Release
on:
push:
tags:
- "v*"
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: release-assets
path: dist/
The top-level permissions: {} is intentional. It makes every permission grant visible at the job where it is needed. The build job can read the tagged source and upload its output, but it cannot create a release, push a commit, or alter repository contents.
Protect v* tags before adding a publishing token
A protected branch does not automatically protect release tags. Configure a tag protection rule or repository ruleset that targets v*. The exact GitHub interface varies by repository settings and plan, but the policy goal is consistent: only a small release-manager group or tightly controlled automation can create matching tags, and routine contributors cannot delete or move them.
For most teams, use these controls:
- Target the pattern
v*, not every tag. This leaves experimental tags such asci-test-42available for normal automation. - Restrict tag creation to maintainers who own releases, usually two or more people rather than one personal account.
- Restrict updates and deletion for protected version tags. A corrected release should normally be
v1.8.1, not a force-movedv1.8.0. - Keep the bypass list short and review it like production access. A broad “repository administrators can always bypass” rule weakens the whole release boundary.
- Test the policy using a non-production tag such as
v0.0.0-policy-testbefore relying on it during an incident.
The tradeoff nobody enjoys is operational: immutable tags make release mistakes visible. If the wrong commit receives v1.8.0, you may need to revoke the release and publish v1.8.1 rather than silently retagging. That friction is useful because package registries, deployment systems, and users may already have consumed the original artifact.
Path 1: a release-creation action plus a separate asset upload step
The marketplace action comnoco/create-release is a practical choice when your release process needs an explicit “create the release” step. Its project is based on GitHub’s unmaintained actions/create-release action and states that it has been updated for current GitHub APIs. This path fits teams that want to create a draft first, generate release text elsewhere, or insert approval-oriented workflow steps between creation and publication.
The important limitation is architectural rather than functional: creating release metadata and uploading assets become separate operations. If the release action succeeds and the upload command fails, you have a GitHub Release with no downloadable binary. Make that state deliberate by creating a draft release first, uploading the files, then publishing only after the upload succeeds.
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- uses: actions/download-artifact@v4
with:
name: release-assets
path: dist
- name: Create draft release
uses: comnoco/create-release@v2.0.5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref_name }}
release_name: Release ${{ github.ref_name }}
draft: true
prerelease: false
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ github.ref_name }}" dist/*
Pin third-party actions to a reviewed commit SHA in production, even when the readable example uses a release tag such as v2.0.5. A tag can be moved by its publisher; a commit SHA identifies the action code your release job actually executes.
This approach is best when release creation is only one step in a larger process: create draft, attach a checksum file, run an external signing or verification process, and publish. It is more moving parts, but that can be a feature when partial releases must remain visibly incomplete rather than accidentally public.
Path 2: use softprops/action-gh-release for release metadata and files together
softprops/action-gh-release is usually the shorter path when the release body and artifacts are ready at the end of one job. It creates GitHub Releases and accepts files to upload, so there is less glue code and fewer boundaries where a draft can be created without its assets.
For a repository producing a tarball, a ZIP file, and checksums in dist/, the release job can look like this:
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- uses: actions/download-artifact@v4
with:
name: release-assets
path: dist
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
generate_release_notes: true
files: |
dist/*.tar.gz
dist/*.zip
dist/SHA256SUMS
The decision rule is simple: choose softprops/action-gh-release when “release metadata plus these known files” is one atomic publishing concern for your team. Choose a release-creation action plus explicit upload commands when draft handling, custom publication sequencing, or an external approval system matters more than minimizing YAML.
Neither choice removes the need for protected tags or scoped permissions. Both actions ultimately need repository contents write access to create or update a GitHub Release. The security difference comes from giving that authority only to the publishing job, not from the name of the marketplace action.
Keep write authority out of dependency installation and builds
The release job is the only job above with contents: write. That separation matters because dependency installation and build scripts are high-churn execution points. A JavaScript package install can execute lifecycle scripts; a packaging tool can run arbitrary project hooks; a contributor can change a build command in a pull request.
Do not combine this into one convenient job:
permissions:
contents: write
steps:
- run: npm ci
- run: npm run build
- run: publish-release
That layout gives every command before publication the token capable of creating releases. Instead, build with read-only permissions, transfer only the output using workflow artifacts, then publish in a clean job. The release job still executes an action and downloads artifacts, so it is not a perfect trust boundary, but it dramatically reduces the amount of code that runs while a write-capable token is available.
Also avoid replacing GITHUB_TOKEN with a broad personal access token just because an action example shows one. The workflow token is short-lived and can be restricted through job-level permissions. A long-lived token stored as a secret creates a second access path that is harder to audit and easier to reuse outside the intended workflow.
Add integrity checks that make asset mistakes obvious
GitHub Release assets are useful only if consumers can connect them to the tag and verify what they downloaded. At minimum, publish a checksum manifest alongside the binaries. For example, generate it in the read-only build job after creating the files:
cd dist
sha256sum *.tar.gz *.zip > SHA256SUMS
The release job then uploads SHA256SUMS with the archives. A user can verify a Linux archive with:
sha256sum --check SHA256SUMS
Be careful with globs. If your build produces no .zip file on a given platform, a broad release configuration may fail or publish only part of what you expected. Make the expected output list explicit, or split platform builds into separate jobs that each upload a named artifact. For example, use artifact names such as linux-amd64, macos-arm64, and windows-amd64 instead of one ambiguous dist artifact.
Finally, inspect the resulting release once with a non-critical version: confirm the release points at the expected tag, the generated notes do not expose unwanted content, and all assets have checksums. This takes about one release cycle to validate and prevents the much harder task of explaining why a public v1.8.0 download does not match its source.
Implement the safer version this week
- Create a tag rule or ruleset for
v*; restrict creation, updates, and deletion to release maintainers. - Change the workflow trigger from a branch push to
push.tags: ["v*"]. - Set workflow-wide
permissions: {}, then grantcontents: readto builds andcontents: writeonly to the release job. - Pick one path:
softprops/action-gh-releasefor direct metadata-and-files publishing, orcomnoco/create-releaseplus explicit upload steps for controlled draft sequencing. - Pin every third-party action used by the write-capable job to a reviewed commit SHA.
- Publish a checksum manifest and test one protected-tag release end to end before the next customer-facing version.
The durable rule is that a GitHub Release should be the consequence of a controlled tag, not the reward for any job that happens to finish on main. Once that boundary exists, choosing between the two release actions becomes a workflow-maintenance decision instead of a repository-security decision.