⚠️ 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:

  1. Audit workflow topology: enumerate .github/workflows/* and determine the smallest coherent set (see the centralized-workflow policy in 042).
  2. 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.
  3. Choose exactly one owner for the tag. Prefer the explicit-dispatch manual publication model and unified external-tag lane.
  4. For manual release-* dispatch, compute the tag, push it with GITHUB_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).
  5. Remove publish-draft jobs that independently create a draft when another release creator exists.
  6. Remove placeholder promote-release jobs. If drafts are genuinely required, promote the exact existing release by ID.
  7. Do not set run_release=true for release: published in the primary publisher router.
  8. Remove || true around release creation. Duplicate creation is an error that should be visible.
  9. 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 --snapshot does not publish a normal GitHub Release.
  1. 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:

  1. Assuming default GITHUB_TOKEN tag pushes start another workflow:

    • actions/checkout using default credentials
    • followed by git push origin "$TAG"
    • combined with the expectation that on: push: tags starts the release run. Fix: Replace this broken pattern with either explicit workflow/repository dispatch using GITHUB_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 without RELEASE_PAT/GH_PAT; the better general guidance is to avoid needing that repository secret in the first place.
  2. Using a PAT/App token solely to force the second run without validation:

    • Repositories that added a TAG_PUSH_TOKEN to bypass the GITHUB_TOKEN limitation 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.

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.