DEV Community

Cover image for Migrate Bitbucket Pipelines to GitHub Actions
Kay
Kay

Posted on Originally published at latchkey.dev

Migrate Bitbucket Pipelines to GitHub Actions

To migrate Bitbucket Pipelines to GitHub Actions, treat each Bitbucket step as a GitHub job, not a GitHub step: a Bitbucket step starts its own Docker container, so the files it shares travel as artifacts, exactly as between GitHub jobs. Then map caches to actions/cache, services to services: with mapped ports, deployment to environment: plus concurrency:, each pipe to an action or a CLI call, and size to a runs-on label.

Eleven Bitbucket Pipelines concepts, from start conditions to size, beside their GitHub Actions equivalents
Each Bitbucket Pipelines concept and the GitHub Actions construct that replaces it, with the behavior that differs. The second row decides the shape of the file.

A bitbucket-pipelines.yml reads like a GitHub workflow with different key names, and that is the trap. The unit of isolation is different: in Bitbucket, each step is a fresh container with a fresh clone, while a GitHub job keeps one workspace across all of its steps.

Get that one mapping right and most of the file follows. This page gives the full concept map, a checklist of what to collect before you start, and a working before and after with a service database, a cache, a parallel group, an artifact and an OIDC deploy.

The concept map

The rows below follow Atlassian's step options reference and GitHub's workflow syntax, read 2026-11-12. Most rows are a rename; slow down where a Bitbucket step and a GitHub step behave differently, starting with the second row.

Bitbucket Pipelines GitHub Actions What changes
default, branches, pull-requests, tags, custom on: push, pull_request, push: tags, workflow_dispatch default runs on every push without a branch pipeline; GitHub has no fallback section
step, its own Docker container A job, on its own runner Files cross only as artifacts, in both
script lines run: steps in one job Steps in one job share the workspace
image container:, or a setup action Job containers need a Linux runner
parallel Jobs with no needs, or a matrix Bitbucket fail-fast defaults to false, a GitHub matrix to true
caches, with key: files actions/cache with a hashFiles() key Bitbucket saves under 1 GB compressed, clears after 1 week; GitHub 10 GB, 7 days unused
artifacts upload-artifact, download-artifact Bitbucket keeps 14 days and 1 GB; GitHub up to 90 days
services services: Map ports: to reach it on localhost; Bitbucket allows 5 per step
deployment, trigger: manual environment: and concurrency: Required reviewers on private repositories need GitHub Enterprise
pipe uses: an action, or a CLI in run: Pipe variables become with: inputs
size: 1x to 32x A runs-on label 2x is 4 CPU and 8 GB; latchkey-small is 2 vCPU and 8 GB

Bitbucket figures are from Atlassian Support: step options, cache dependencies, use artifacts in steps, databases and service containers, and deployment concurrency control. GitHub figures are from its workflow syntax and dependency caching references and the retention-days input of actions/upload-artifact v7.0.2. All read 2026-11-12.

The migration checklist

Collect these from the Bitbucket repository settings and the YAML before you write the workflow.

  • Labels. Every size: value and every self-hosted runs-on: label. Atlassian's table gives 1x as 2 CPU and 4 GB, 2x as 4 CPU and 8 GB, and 4x and above on paid plans only, at a multiple of the 1x build minutes.
  • Secrets. Repository, workspace and deployment variables, marked Secured or not. Secured ones become GitHub secrets, deployment variables become environment secrets, and the rest become configuration variables.
  • Cache keys. Predefined caches such as node have no key at all; Atlassian notes that predefined caches do not support file-based keys. Give each one a hashFiles() key in actions/cache, or use the cache: input of a setup action.
  • Matrix. Bitbucket has no matrix: a parallel block of near-identical steps is one, written out by hand. Collapse it into strategy.matrix, and set fail-fast: false if a leg must not cancel the others.
  • Artifacts. Every artifacts: path, and which later step reads it. Steps that only exist to pass dist/ along can often merge into one job and drop the artifact.
  • Concurrency. Bitbucket pauses a later deployment to an environment that already has one in progress, until someone resumes or reruns it. GitHub does nothing of the kind unless the deploy job sets concurrency:, and only queue: max keeps every waiting deploy.
  • Max time. max-time defaults to 120 minutes in Bitbucket; timeout-minutes defaults to 360 in GitHub. Carry the shorter limit across.

Before: bitbucket-pipelines.yml

Tests run on pull requests and on main against a Postgres service, a build runs beside them on main, and a manual deploy step pushes dist/ to S3 through the atlassian/aws-s3-deploy pipe with OIDC. Note that the Build step and the Deploy step are separate containers, so dist/** has to be declared as an artifact to reach the deploy.

image: node:22

definitions:
  caches:
    npm-lock:
      key:
        files:
          - package-lock.json
      path: ~/.npm
  services:
    postgres:
      image: postgres:16
      variables:
        POSTGRES_PASSWORD: test
  steps:
    - step: &test
        name: Test
        caches:
          - npm-lock
        services:
          - postgres
        script:
          - npm ci
          - npm test

pipelines:
  pull-requests:
    '**':
      - step: *test
  branches:
    main:
      - parallel:
          steps:
            - step: *test
            - step:
                name: Build
                caches:
                  - npm-lock
                script:
                  - npm ci
                  - npm run build
                artifacts:
                  - dist/**
      - step:
          name: Deploy
          deployment: production
          trigger: manual
          oidc: true
          script:
            - pipe: atlassian/aws-s3-deploy:2.1.0
              variables:
                AWS_DEFAULT_REGION: 'us-east-1'
                AWS_OIDC_ROLE_ARN: $AWS_DEPLOY_ROLE_ARN
                S3_BUCKET: $S3_BUCKET
                LOCAL_PATH: 'dist'
Enter fullscreen mode Exit fullscreen mode

After: .github/workflows/ci.yml

Three jobs. test runs on both events, build and deploy only on a push to main. The Postgres service gets ports: 5432:5432, because the job runs on the runner rather than in a container, and that mapping is what makes localhost:5432 work as it did in Bitbucket. The health check holds the job until the database accepts connections.

The manual trigger becomes the production environment, which pauses the job for a reviewer when the environment has required reviewers; on a private repository that needs GitHub Enterprise, so check your plan. The pipe becomes aws-actions/configure-aws-credentials with id-token: write, followed by the AWS CLI call the pipe was making.

The concurrency: block stands in for Bitbucket's deployment lock. With queue: max, a second push to main waits for the first deploy and then starts on its own. Without it, GitHub keeps one pending run per group, and a third push cancels the second one while it waits.

name: ci
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: npm
      - run: npm ci
      - run: npm test

  build:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: npm
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: dist
          path: dist

  deploy:
    needs: [test, build]
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: production
      queue: max
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: dist
          path: dist
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1
      - run: aws s3 sync dist "s3://${{ vars.S3_BUCKET }}/"
Enter fullscreen mode Exit fullscreen mode

Bitbucket's paused deployments are from Atlassian Support, deployment concurrency control. The default queue: single and queue: max (up to 100 pending) are from GitHub Docs: Control the concurrency of workflows and jobs. Both read 2026-11-12.

Pipes are the slow part

Atlassian describes a pipe as a script that lives in a Docker container, configured through variables:. There is no automatic translation, but most popular pipes wrap a vendor CLI, and the vendor usually publishes a GitHub action for the same job: credentials from one action, then the CLI in run:. Read the pipe's README for the command it runs and the defaults it sets; aws-s3-deploy, for example, documents a DELETE_FLAG that defaults to false, which is why the sync above has no --delete.

Bitbucket's OIDC token arrives as BITBUCKET_STEP_OIDC_TOKEN when a step sets oidc: true. On GitHub the token is requested by the action itself, and only when the job grants id-token: write. Your cloud's trust policy needs a new provider and a new subject either way, because the issuer changes.

Caches: from named caches to keys

Bitbucket saves a cache only when it is empty and the build succeeds, and clears it after a week. actions/cache saves when no entry matches the exact key, which is why the key must change when the lockfile does. For npm, the cache: npm input on actions/setup-node does this for you; for anything else, write the key yourself.

```.github/workflows/ci.yml (cache step)

  • uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: npm-${{ runner.os }}- ```

What GitHub Actions Importer covers

GitHub Actions Importer has a bitbucket subcommand, covered with its limits on GitHub Actions Importer. It is worth running for the triggers, the steps and the caches. Check what it writes against the concept map: the step-to-job boundary, the service ports and the deployment concurrency are where a mechanical conversion is most likely to be correct YAML and wrong behavior.

The pipe's aws s3 sync call and DELETE_FLAG default: the atlassian/aws-s3-deploy 2.1.0 README and pipe.sh, read 2026-11-12.

FAQ

What is the GitHub Actions equivalent of a Bitbucket pipe?

An action, used with uses: and configured with with:. Most pipes wrap a vendor CLI, so the usual translation is the vendor's credentials action followed by the same CLI command in a run: step, which also makes the command visible in the workflow file.

How do I run steps in parallel in GitHub Actions like Bitbucket parallel?

Make them separate jobs with no needs: between them; GitHub starts independent jobs at the same time. When the parallel steps differ only in one value, such as a test shard, use strategy.matrix, and set fail-fast: false to keep Bitbucket's default of letting the other legs finish.

How do I pass files between jobs in GitHub Actions?

Upload them with actions/upload-artifact in the job that makes them, and download them by name with actions/download-artifact in a job that lists the first one under needs:. Steps in one job need none of this, because they share the workspace, which is why merging two Bitbucket steps into one job can drop an artifact entirely.

How do I use a service container like Postgres in GitHub Actions?

Declare it under the job's services: with an image: and env:. When the job runs directly on the runner, map the port with ports: - 5432:5432 and connect to localhost; when the job runs in a container:, reach the service by its label as the host name instead.

References

Top comments (0)