Update Jira releases from GitHub releases with GitHub Actions

Update Jira releases from GitHub releases with GitHub Actions

~ 6 min read


Why do this?

If your team ships from GitHub but tracks release versions in Jira, it is easy for the two to drift apart. You publish v1.8.0 on GitHub, but the matching Jira release still sits in an unreleased state.

This setup makes GitHub Releases the source of truth. You create a tagged release in GitHub, and a second workflow marks the matching Jira version as released through the Jira Cloud REST API.

The example below assumes this naming convention:

  • GitHub tag: v1.8.0
  • Jira version: 1.8.0

If you use the same value in both systems, remove the v prefix normalisation step.

What you need first

Before adding the workflows, make sure you have:

  1. A GitHub repository with Actions enabled.
  2. A Jira Cloud project using Releases / Versions.
  3. An Atlassian API token for the user that is allowed to manage project versions.
  4. Repository secrets for the Jira connection.

Add these GitHub Actions secrets to your repository:

  • GH_RELEASE_TOKEN: a token that can create releases in this repository.
  • JIRA_BASE_URL: for example https://your-domain.atlassian.net
  • JIRA_EMAIL: the Atlassian account email used for the API call
  • JIRA_API_TOKEN: the Atlassian API token
  • JIRA_PROJECT_KEY: for example APP
  • JIRA_PROJECT_ID: the numeric Jira project ID used when creating a version if it does not exist yet

Use a dedicated GH_RELEASE_TOKEN rather than the built-in GITHUB_TOKEN for release creation. GitHub documents that events created by GITHUB_TOKEN do not start new workflow runs, which matters here because we want a second workflow to run on release.published.

Useful docs:

Dark DevOps concept art showing commit, build, and release tag creation as one connected GitHub release pipeline

Workflow 1: create the GitHub tag and release

Create .github/workflows/create-release.yml:

name: Create Release

on:
    workflow_dispatch:
        inputs:
            version:
                description: "Release version, for example 1.8.0"
                required: true
                type: string
            prerelease:
                description: "Mark as a prerelease"
                required: false
                default: false
                type: boolean

permissions:
    contents: write

jobs:
    create-release:
        runs-on: ubuntu-latest

        steps:
            - name: Normalise the tag
              id: vars
              shell: bash
              run: |
                  set -euo pipefail
                  VERSION="${{ inputs.version }}"
                  VERSION="${VERSION#v}"
                  echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"

            - name: Create the GitHub release
              env:
                  GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
                  TAG: ${{ steps.vars.outputs.tag }}
                  PRERELEASE: ${{ inputs.prerelease }}
              shell: bash
              run: |
                  set -euo pipefail

                  if gh release view "$TAG" >/dev/null 2>&1; then
                      echo "Release $TAG already exists" >&2
                      exit 1
                  fi

                  args=(
                      "$TAG"
                      "--target" "$GITHUB_SHA"
                      "--title" "$TAG"
                      "--generate-notes"
                  )

                  if [ "$PRERELEASE" = "true" ]; then
                      args+=("--prerelease")
                  fi

                  gh release create "${args[@]}"

This workflow is triggered manually because it uses workflow_dispatch. That means GitHub shows a Run workflow button in the Actions tab for this workflow, and the version input appears as a form field when you launch it.

How to trigger the release workflow

After committing .github/workflows/create-release.yml to your default branch:

  1. Open your repository on GitHub.
  2. Go to Actions.
  3. Select Create Release in the left-hand workflow list.
  4. Click Run workflow.
  5. Enter a value for version, for example 1.8.0.
  6. Optionally set prerelease to true.
  7. Click Run workflow to start the job.

GitHub passes that form value into ${{ inputs.version }} inside the workflow. That is what this line is reading:

VERSION="${{ inputs.version }}"

If you prefer triggering it from the command line, you can do the same thing with the GitHub CLI:

gh workflow run create-release.yml -f version=1.8.0 -f prerelease=false

If the workflow does not appear in the Actions tab yet, make sure the workflow file is already on the repository default branch.

What this does:

  1. Accepts a version like 1.8.0.
  2. Normalises it to v1.8.0.
  3. Creates the GitHub release and the missing Git tag in one step.
  4. Publishes generated release notes.

The tag is created from the commit the workflow is running against. If you want stricter control, change --target "$GITHUB_SHA" to a specific commit SHA or branch tip that matches your release process.

Dark DevOps concept art showing a published release tag flowing into a structured version board update

Workflow 2: mark the Jira version as released

Create .github/workflows/sync-jira-release.yml:

name: Sync Jira Release

on:
    release:
        types:
            - published

jobs:
    sync-jira-release:
        runs-on: ubuntu-latest

        steps:
            - name: Update the Jira version
              env:
                  JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
                  JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
                  JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
                  JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }}
                  JIRA_PROJECT_ID: ${{ secrets.JIRA_PROJECT_ID }}
                  TAG_NAME: ${{ github.event.release.tag_name }}
                  RELEASED_AT: ${{ github.event.release.published_at }}
              shell: bash
              run: |
                  set -euo pipefail

                  VERSION_NAME="${TAG_NAME#v}"
                  RELEASE_DATE="${RELEASED_AT%%T*}"

                  versions_json="$(
                      curl --fail-with-body --silent --show-error \
                          --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
                          --header "Accept: application/json" \
                          "$JIRA_BASE_URL/rest/api/3/project/$JIRA_PROJECT_KEY/versions"
                  )"

                  version_json="$(
                      printf '%s' "$versions_json" | jq -cer --arg name "$VERSION_NAME" '
                          map(select(.name == $name)) | first
                      ' || true
                  )"

                  if [ -z "$version_json" ] || [ "$version_json" = "null" ]; then
                      create_payload="$(
                          jq -n \
                              --arg name "$VERSION_NAME" \
                              --arg releaseDate "$RELEASE_DATE" \
                              --argjson projectId "$JIRA_PROJECT_ID" \
                              '{
                                  name: $name,
                                  projectId: $projectId,
                                  released: true,
                                  releaseDate: $releaseDate
                              }'
                      )"

                      curl --fail-with-body --silent --show-error \
                          --request POST \
                          --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
                          --header "Accept: application/json" \
                          --header "Content-Type: application/json" \
                          --data "$create_payload" \
                          "$JIRA_BASE_URL/rest/api/3/version"

                      echo "Created and released Jira version $VERSION_NAME"
                      exit 0
                  fi

                  version_id="$(printf '%s' "$version_json" | jq -r '.id')"

                  update_payload="$(
                      printf '%s' "$version_json" | jq -c --arg releaseDate "$RELEASE_DATE" '
                          {
                              name,
                              projectId,
                              released: true,
                              releaseDate: $releaseDate
                          }
                          + (if has("description") and .description != null then {description} else {} end)
                          + (if has("archived") and .archived != null then {archived} else {} end)
                      '
                  )"

                  curl --fail-with-body --silent --show-error \
                      --request PUT \
                      --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \
                      --header "Accept: application/json" \
                      --header "Content-Type: application/json" \
                      --data "$update_payload" \
                      "$JIRA_BASE_URL/rest/api/3/version/$version_id"

                  echo "Marked Jira version $VERSION_NAME as released"

This workflow listens for the published GitHub release event, looks up the Jira version by name, and then:

  • updates it if it already exists
  • creates it first if it does not

The release date is taken from the GitHub release timestamp, not from the runner clock. That keeps the dates consistent if the job queues for a few minutes before starting.

How the mapping works

The key line is this one:

VERSION_NAME="${TAG_NAME#v}"

That converts v1.8.0 into 1.8.0. If your Jira versions also include the v, remove that line and use TAG_NAME directly.

Keep the mapping boring and predictable. If you start translating branch names, app names, or environment names inside the workflow, the release process gets harder to debug.

Verify the flow end to end

Run the manual workflow once with a test version such as 1.8.0. Then check these points:

  1. A GitHub release called v1.8.0 exists.
  2. The repository now has a v1.8.0 tag.
  3. The Jira project has a version 1.8.0.
  4. That Jira version is marked as released with the expected release date.

If the GitHub release appears but Jira does not update, check the sync-jira-release workflow run first. Most failures come down to one of three things:

  • GH_RELEASE_TOKEN was replaced with GITHUB_TOKEN, so the second workflow never triggered
  • JIRA_PROJECT_KEY or JIRA_PROJECT_ID points at the wrong project
  • the Jira user behind the API token does not have permission to manage versions

A couple of useful variations

You can adjust this pattern without changing the basic flow:

  • If you already create tags somewhere else, delete the first workflow and keep only the release.published sync workflow.
  • If you want release notes from a file instead of generated notes, replace --generate-notes with --notes-file CHANGELOG.md.
  • If you never want Jira versions auto-created, remove the POST branch and fail when the version cannot be found.

Summary

The clean way to keep Jira releases aligned with GitHub releases is to treat the GitHub release as the event that closes the loop. Create the tag and release in one workflow, then let a second workflow update the Jira version through the official Jira API.

Once this is in place, release status stops being a manual admin task and becomes part of the release itself.

all posts →