Release-Safe Single-Owner GitHub CI/CD
⚠️ IMPORTANT: THIS ARTICLE IS SUPERSEDED FOR CI GENERATION
This article is no longer the generation specification. Use the new canonical reference at /blog/post/2026/043-canonical-github-actions-ci-cd-super-reference/ to create or update this repository’s CI.
This is the focused release-safety companion to the current general CI guidance in 042-simplified-github-ci-release-safe. Use 042 as the canonical document when creating or upgrading an entire CI workflow, and use this article for the detailed single-release-owner rationale and migration checks. These rules supersede the older 006-github-ci-and-deploy, 011-simplified-github-ci, and 028-simplified-github-ci-updated draft-promotion examples where they conflict.
The important rule is simple:
For one tag, exactly one job or release tool owns creation and publication of the GitHub Release.
Artifact builders may produce and upload artifacts. Other jobs may react after publication. They must not independently create another release for the same tag.
This rule exists because a workflow can otherwise create a draft release with softprops/action-gh-release, also run gh release create, and also run again for release: published. GitHub permits draft releases that do not behave like the canonical published release, so this can leave untagged-* draft entries behind even when a normal release for the version is eventually published.
The failure pattern to remove
Do not combine patterns like these for the same version:
1manual-gh-release:
2 # ...
3 run: gh release create "$TAG" --generate-notes || true
4
5publish-draft:
6 # ...
7 uses: softprops/action-gh-release@v2
8 with:
9 draft: true
10 tag_name: ${{ needs.prepare-release-tag.outputs.release_tag || github.ref_name }}
11
12promote-release:
13 # Placeholder is not promotion.
14 run: echo "Promotion step placeholder"
Do not route release: published back into the same release-producing lane either:
1release)
2 run_release=true
3 ;;
That event is emitted after a release has been published. Treat it as a downstream notification event unless you have an explicit, idempotent recovery workflow.
Also do not hide duplicate-release failures with || true. A failed gh release create may be the signal that another job already created a draft or published release.
The manual credential recursion trap
For example, consider a failure like the one in Actions run 33820455345 (job 100861817524). A configured PAT was present and checkout succeeded, but the later git push failed with HTTP 403 because the credential lacked usable repository write permission. Do not blindly attempt to push tags and assume success without verifying permissions.
Canonical model: manual dispatch pushes the tag and publishes the release
The preferred default model computes the tag, pushes it using GITHUB_TOKEN, and then explicitly dispatches the publisher using GITHUB_TOKEN. GitHub’s event-recursion rule suppresses ordinary events (like push) caused by GITHUB_TOKEN to prevent infinite loops. However, GitHub explicitly makes workflow_dispatch and repository_dispatch exceptions to that recursion suppression. By manually dispatching the workflow at the newly pushed tag using GITHUB_TOKEN, we avoid needing a PAT (Personal Access Token) while maintaining a separate, canonical release-publisher run.
Manual release
1manual release dispatch
2-> compute and validate exactly one tag
3-> push the tag with the normal GITHUB_TOKEN
4-> explicitly workflow-dispatch the publisher workflow at that tag ref using GITHUB_TOKEN
5-> publisher run performs normal tested release build
6-> exactly one GitHub Release owner publishes
Independent tag push
1human/external vX.Y.Z push
2-> normal tag-triggered workflow
3-> required gates
4-> same publisher publishes exactly once
Router
1jobs:
2 route:
3 runs-on: ubuntu-latest
4 outputs:
5 run_code_checks: ${{ steps.route.outputs.run_code_checks }}
6 run_release: ${{ steps.route.outputs.run_release }}
7 steps:
8 - id: route
9 shell: bash
10 env:
11 EVENT_NAME: ${{ github.event_name }}
12 REF: ${{ github.ref }}
13 run: |
14 set -euo pipefail
15
16 run_code_checks=false
17 run_release=false
18
19 case "$EVENT_NAME" in
20 push)
21 run_code_checks=true
22 if [[ "$REF" == refs/tags/v* ]]; then
23 run_release=true
24 fi
25 ;;
26 pull_request)
27 run_code_checks=true
28 ;;
29 workflow_dispatch)
30 # A manual release mode pushes the tag and explicitly dispatches the publisher.
31 run_code_checks=true
32 run_release=true
33 ;;
34 release)
35 # Downstream notification only. Never create the same release again here.
36 ;;
37 esac
38
39 echo "run_code_checks=$run_code_checks" >> "$GITHUB_OUTPUT"
40 echo "run_release=$run_release" >> "$GITHUB_OUTPUT"
Release Context Gate: normalize tag and safely push if manual
Keep the manual and external-tag paths mutually exclusive so they cannot create competing releases. We introduce a common release-context job that runs for BOTH push tags and workflow_dispatch manual releases after all validation gates successfully pass. It normalizes the tag, safely pushes it if it was a manual request, and exports the tag for downstream publishers.
(This snippet is schematic. In a complete workflow, this gate must explicitly depend on every repository-appropriate validation job—see 042 for the full dynamic gate pattern.)
1 release-validation:
2 name: Release Validation Gate
3 needs: [route, prepare-release-tag, test] # <-- depend on all actual required tests
4 if: ${{ !failure() && !cancelled() && needs.test.result == 'success' }}
5 runs-on: ubuntu-latest
6 steps:
7 - run: echo "Validation complete"
8
9 release-context:
10 name: Release Context & Gate
11 needs: [route, prepare-release-tag, release-validation, build-release-artifacts]
12 if: ${{ !failure() && !cancelled() && needs.route.outputs.run_release == 'true' }}
13 runs-on: ubuntu-latest
14 permissions:
15 contents: write
16 actions: write
17 outputs:
18 release_tag: ${{ steps.export.outputs.release_tag }}
19 steps:
20 - uses: actions/checkout@v7
21 with:
22 fetch-depth: 0
23
24 - name: Normalize and push tag
25 id: export
26 shell: bash
27 env:
28 GH_TOKEN: ${{ github.token }}
29 REF_NAME: ${{ github.ref_name }}
30 EVENT_NAME: ${{ github.event_name }}
31 INPUT_MODE: ${{ inputs.mode }}
32 REF_TYPE: ${{ github.ref_type }}
33 NEEDS_RELEASE_TAG: ${{ needs.prepare-release-tag.outputs.release_tag }}
34 run: |
35 set -euo pipefail
36
37 TAG="$NEEDS_RELEASE_TAG"
38 if [[ -z "$TAG" ]]; then
39 TAG="$REF_NAME"
40 fi
41 echo "release_tag=$TAG" >> "$GITHUB_OUTPUT"
42
43 if [[ "$EVENT_NAME" == "push" ]]; then
44 exit 0
45 fi
46
47 if [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_MODE" == "publish-tag" ]]; then
48 if [[ "$REF_TYPE" != "tag" ]]; then
49 echo "Error: publish-tag mode invoked on a non-tag ref." >&2
50 exit 1
51 fi
52 echo "Running in internal publisher mode; tag is immutable context."
53 exit 0
54 fi
55
56 git fetch --tags --force
57
58 # Check for existing tag, handling annotated tags with ^{}
59 REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/$TAG^{}" | awk '{print $1}')
60 if [[ -z "$REMOTE_TAG_SHA" ]]; then
61 REMOTE_TAG_SHA=$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')
62 fi
63
64 if [[ -n "$REMOTE_TAG_SHA" ]]; then
65 if [[ "$REMOTE_TAG_SHA" == "${GITHUB_SHA}" ]]; then
66 echo "Tag $TAG already exists on remote and points to the correct SHA. Continuing safely."
67 else
68 echo "Tag $TAG already exists on remote but points to a different commit ($REMOTE_TAG_SHA). Failing." >&2
69 echo "To retry a failed publication for this exact version, ensure release_version_override is used and GITHUB_SHA matches." >&2
70 exit 1
71 fi
72 else
73 # Explicitly anchor the new tag to the validated commit before pushing
74 git tag "$TAG" "${GITHUB_SHA}"
75 git push origin "refs/tags/$TAG"
76 fi
77
78 # Final verification to ensure the remote tag matches our expected commit
79 VERIFY_SHA=$(git ls-remote --tags origin "refs/tags/$TAG^{}" | awk '{print $1}')
80 if [[ -z "$VERIFY_SHA" ]]; then
81 VERIFY_SHA=$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')
82 fi
83 if [[ "$VERIFY_SHA" != "${GITHUB_SHA}" ]]; then
84 echo "Tag push failed or remote verification failed (expected ${GITHUB_SHA}, got ${VERIFY_SHA})." >&2
85 exit 1
86 fi
87
88 # Explicitly dispatch the publisher workflow at the new tag ref
89 if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
90 gh workflow run "ci.yml" --ref "$TAG" -f mode="publish-tag"
91 fi
Do NOT expect the manual tag push to start another workflow when using GITHUB_TOKEN because ordinary events generated using GITHUB_TOKEN are suppressed to prevent recursive workflow loops. A workflow run pushing a tag using the normal GITHUB_TOKEN must NOT be assumed to recursively trigger an on: push: tags: event. Instead, the preferred secret-free architecture explicitly dispatches the workflow at the new tag, relying on workflow_dispatch and repository_dispatch which are the relevant recursion exceptions. Do not teach readers to introduce a PAT merely to make the tag push recursively trigger another workflow unless they intentionally choose and document that alternate architecture. The publisher mode verifies it is running at an eligible tag and cannot recursively create/push another tag or dispatch itself again.
Non-GoReleaser projects
After tested artifacts fan in, the one release owner job creates the published release and attaches the files. We run it for both external tag pushes and manual mode (where it depends on the tag being pushed).
1 github-release:
2 name: Publish GitHub release
3 needs: [route, build-release-artifacts, release-context]
4 if: ${{ !failure() && !cancelled() && needs.route.outputs.run_release == 'true' && startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-tag')) }}
5 runs-on: ubuntu-latest
6 permissions:
7 contents: write
8 discussions: write
9 steps:
10 - name: Collect release artifacts
11 uses: actions/download-artifact@v5
12 with:
13 path: dist-release
14 pattern: '*-release'
15 merge-multiple: true
16
17 - name: Create published GitHub release and upload assets
18 uses: softprops/action-gh-release@v2
19 with:
20 draft: false
21 tag_name: ${{ needs.release-context.outputs.release_tag }}
22 prerelease: ${{ contains(needs.release-context.outputs.release_tag, '-rc') || contains(needs.release-context.outputs.release_tag, '-alpha') || contains(needs.release-context.outputs.release_tag, '-beta') || contains(needs.release-context.outputs.release_tag, '-test') }}
23 generate_release_notes: true
24 files: dist-release/**
There is no separate publish-draft, no placeholder promote-release, and no competing gh release create job.
If a project intentionally requires human-reviewed drafts, the same single-owner rule still applies: one job creates the draft, later code must locate and patch that same release ID to draft=false, and no other job may call gh release create for the tag.
GoReleaser projects
If GoReleaser publishes the GitHub Release, GoReleaser is the release owner.
Ensure the explicitly dispatched publisher has the correct tag context. Because github.ref might be a branch rather than a tag during a manual workflow_dispatch run, you must explicitly pass the computed tag to your publisher. For example, if GoReleaser requires GORELEASER_CURRENT_TAG or a local tag, configure it correctly:
1 goreleaser:
2 needs: [route, release-context]
3 if: ${{ !failure() && !cancelled() && needs.route.outputs.run_release == 'true' && startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.mode == 'publish-tag')) }}
4 runs-on: ubuntu-latest
5 permissions:
6 contents: write
7 # packages: write # (Uncomment if GoReleaser publishes to GHCR/GitHub Packages)
8 steps:
9 - uses: actions/checkout@v7
10 with:
11 fetch-depth: 0
12 fetch-tags: true
13 - uses: actions/setup-go@v7
14 with:
15 go-version: stable
16 - uses: goreleaser/goreleaser-action@v7
17 with:
18 distribution: goreleaser
19 version: latest
20 args: release --clean
21 env:
22 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23 GORELEASER_CURRENT_TAG: ${{ needs.release-context.outputs.release_tag }}
Prerelease classification: If the repository accepts SemVer prerelease tags (e.g. v1.2.3-rc.1, v1.2.3-beta.2), its .goreleaser.yaml configuration must explicitly preserve prerelease classification. Ensure your .goreleaser.yaml contains:
1release:
2 prerelease: auto
Do not add softprops/action-gh-release, gh release create, or a second release-producing release: published lane around it.
release: published is downstream
It can still be useful for work that should happen only after GitHub confirms the release is public, for example:
- updating a web site,
- sending notifications,
- refreshing external metadata,
- producing reports.
Those jobs should consume the published release. They should not create it again.
Idempotent recovery is different from a second owner
If publication fails after a tag has been pushed, a rerun should not blindly attempt to recreate the same tag and fail. A safe, state-aware approach must:
- verify whether the expected tag already exists and points at the expected commit;
- reuse it for recovery when safe;
- fail clearly if it points elsewhere;
- never silently move an existing release tag.
A manually-invoked recovery job may inspect an existing release and upload missing assets, but it should require an explicit version/tag and verify state first. For example:
1set -euo pipefail
2
3gh release view "$TAG" >/dev/null
4# upload only the known missing asset(s)
5gh release upload "$TAG" dist/my-artifact --clobber
That is recovery against the canonical release, not a second publication path.
Migration checklist for existing generated workflows
When updating repositories generated from the older articles:
- Audit workflow topology: enumerate
.github/workflows/*and determine the smallest coherent set (see the centralized-workflow policy in042). - Identify every place that can create a GitHub Release:
gh release create,softprops/action-gh-release, GoReleaser, language-specific publishers, and API calls to/releases. - Choose exactly one owner for the tag. Prefer the explicit-dispatch manual publication model and unified external-tag lane.
- For manual
release-*dispatch, compute the tag, push it withGITHUB_TOKEN, and explicitly dispatch the publisher workflow at that tag ref (or explicitly document a trigger-capable GitHub App/PAT if you specifically want the raw tag push event itself to trigger). - Remove
publish-draftjobs that independently create a draft when another release creator exists. - Remove placeholder
promote-releasejobs. If drafts are genuinely required, promote the exact existing release by ID. - Do not set
run_release=trueforrelease: publishedin the primary publisher router. - Remove
|| truearound release creation. Duplicate creation is an error that should be visible. - Keep build/test/artifact responsibilities logically separate from the one release publication owner. This means:
- logically separate jobs;
- separate permissions;
- separate responsibilities;
not necessarily separate workflow files. Where practical, these jobs belong in the same central workflow specifically so their release dependency relationship can use native needs: edges. See 042 Simplified GitHub CI for the central workflow topology.
10. Preserve release mode semantics:
- normal major/minor/patch releases publish normally;
- RC/alpha/beta pre-releases publish as pre-releases where appropriate;
- test/snapshot modes must not accidentally create normal published releases;
- note that GoReleaser
--snapshotdoes not publish a normal GitHub Release.
- Check the Releases page for old
untagged-*drafts. Fixing the workflow prevents new duplicates; historical drafts should be reviewed and deleted separately if they are obsolete.
Audit searches
These searches are useful across a set of repositories:
1"softprops/action-gh-release" "draft: true"
2"gh release create"
3"manual-gh-release" "publish-draft"
4"release:" "types: [published]"
5"run_release=true"
A repository is not automatically broken merely because it contains one of those strings. The dangerous condition is multiple release owners reaching the same tag/version.
Migration/audit guidance for dangerous tag-push assumptions
Audit repositories for these two dangerous patterns:
Assuming default
GITHUB_TOKENtag pushes start another workflow:actions/checkoutusing default credentials- followed by
git push origin "$TAG" - combined with the expectation that
on: push: tagsstarts the release run. Fix: Replace this broken pattern with either explicit workflow/repository dispatch usingGITHUB_TOKEN(preferred), or an explicitly documented trigger-capable GitHub App/PAT credential. This correction was found in practice when a generated workflow correctly refused to proceed withoutRELEASE_PAT/GH_PAT; the better general guidance is to avoid needing that repository secret in the first place.
Using a PAT/App token solely to force the second run without validation:
- Repositories that added a
TAG_PUSH_TOKENto bypass theGITHUB_TOKENlimitation but fail to validate it. Fix: If you genuinely require the strict tag-push-owner model (where a pushed tag must start a separate workflow), the credential must have Contents write permission for tag pushes. A non-empty secret check (if: env.TAG_PUSH_TOKEN != '') does not prove the token is usable or has the correct permissions. GitHub App credentials are preferable to a broad long-lived PAT where practical. Prefer the secret-free explicit-dispatch manual publication model to remove this credential requirement entirely. Treat a PAT/App token as an optional alternate architecture, not a universal prerequisite. Do not require a PAT solely to force recursive workflow execution.
- Repositories that added a
Agent rule
When generating or upgrading an entire CI workflow, use the current general guide and keep this article as the focused release-safety reference:
1# Agent rules for generation:
2# https://arran4.github.io/blog/post/2026/042-simplified-github-ci-release-safe/
3# Release safety details:
4# https://arran4.github.io/blog/post/2026/041-release-safe-single-owner-github-ci/
The 041 rules take precedence over older CI articles for release ownership, draft creation, tag routing, and release: published behavior.