Thuta Learning
ExercisesDevOps & Toolsbeginner

Exercise: Build a Release Pipeline

What you'll walk away with

  • Explain the core ideas behind Exercise: Build a Release Pipeline
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

Build the mental model

A release pipeline differs from a CI pipeline in one important way. CI runs constantly and its mistakes are cheap; a release runs rarely, in front of an audience, and its mistakes are visible to customers. So the design goals change. You want the thing you tested to be the exact thing you ship, you want the release to be reproducible from a tag rather than from whatever happened to be sitting on a branch, and you want a human in the loop before anything touches production.

Three ideas carry most of that weight. The first is the tag trigger. Writing on: push: tags: ['v*'] means the pipeline is started by an immutable name, so v1.2.3 always refers to exactly one commit and re-running the workflow builds the same tree. The second is build once, reuse everywhere. A pipeline that compiles in the build job and compiles again inside the deploy job has shipped an artifact that nothing in the run ever tested; uploading a bundle with actions/upload-artifact@v4 and pulling it back down with actions/download-artifact@v4 makes the tested bytes and the shipped bytes the same bytes. The third is the environment gate. An environment: block on a job is not decoration. When that environment has required reviewers configured in repository settings, the job pauses and waits for a named human to approve it, and any secrets scoped to that environment stay unavailable to every job that has not passed the gate.

The scaffold below implements the first idea completely, in a build job you should not need to change. The other two ideas are left for you to build.

text
RELEASE PIPELINE: DONE VS TODO
------------------------------
-------------------------------

     git push origin v1.2.3
               |
               v
     [ build ]  -- DONE, leave this alone
     checkout -> npm ci -> npm run build -> tar -> upload
               |
               |  artifact: release-bundle
               v
     [ publish ]  -- TODO (stub only echoes)
     download artifact -> create GitHub Release, attach tarball
     needs: permissions: contents: write
               |
               v
     [ deploy-production ]  -- TODO (stub only echoes)
     environment: production -> WAITS FOR HUMAN APPROVAL
               |
               v
        production is live

Connect it to a real scenario

Before you write the missing jobs, do the setup they depend on, because both will fail in confusing ways if you skip it. In your repository, go to Settings, then Environments, and create one called production. Tick Required reviewers and add yourself. Any job that later declares environment: production will now stop and wait for a click.

Next, the permissions. Creating a Release writes to the repository, and the default GITHUB_TOKEN inside a workflow is read-only in most repositories. The publish job therefore needs a permissions: block of its own granting contents: write, and it belongs at job level rather than workflow level so the build job keeps the smaller set. You do not need a personal access token for this; secrets.GITHUB_TOKEN is enough once the permission is granted.

Then test the whole thing the way it will really be used. Tag a commit locally with git tag v0.0.1-test and push the tag with git push origin v0.0.1-test. Watch the build job produce the artifact, then confirm that the publish job downloads that same artifact instead of rebuilding. When you want to try again, delete the tag both locally and on the remote and re-push it, and delete the draft Release too, or the next run will collide with an existing release of the same name.

Try the working example

yaml
name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the tagged commit
        uses: actions/checkout@v4
      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - name: Install dependencies
        run: npm ci
      - name: Build the release bundle
        run: npm run build
      - name: Package the bundle
        run: tar -czf app-${{ github.ref_name }}.tar.gz dist
      - name: Upload the release artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-bundle
          path: app-${{ github.ref_name }}.tar.gz
          retention-days: 30

  # TODO(you) - replace this stub with a real publish job. It should grant
  # itself contents write permission, download the release-bundle artifact
  # with actions/download-artifact@v4, and create a GitHub Release for the
  # tag in github.ref_name with the tarball attached as an asset.
  publish:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Not implemented yet
        run: echo 'TODO - download release-bundle and publish a GitHub Release'

  # TODO(you) - this job must not run until a human approves it. Add an
  # environment block naming a protected environment (for example the
  # production environment you configured with required reviewers), then
  # replace the echo with the real deployment command.
  deploy-production:
    runs-on: ubuntu-latest
    needs: publish
    steps:
      - name: Not implemented yet
        run: echo 'TODO - deploy the published release to production'
You should see
As written, this scaffold is a working but deliberately incomplete pipeline. Pushing a tag that matches v* starts it. The build job is real and complete: it checks out the tagged commit, installs dependencies with npm ci, runs the production build, packages dist into a tarball named after the tag, and uploads it as an artifact called release-bundle with a thirty day retention. That much genuinely works.

The other two jobs are stubs. The publish job waits for build and then only echoes a TODO message. It never downloads release-bundle, never creates a GitHub Release, and has no contents write permission, so no release object appears on the repository's Releases page. The deploy-production job waits for publish and echoes a second TODO. It has no environment block, so nothing pauses for approval and no environment-scoped secrets are available to it. Both stub jobs report success, which means the whole run finishes green while shipping nothing at all. That green is the trap: until you replace the echo steps, a passing run proves only that the build compiled.

5-minute try-it

Finish the pipeline. You are done when pushing a single tag, with git tag v1.2.3 followed by git push origin v1.2.3, does all three of the following in that order from one workflow run.

One: the project is built exactly once. There must be no second npm run build anywhere downstream; every later job works from the release-bundle artifact that the build job produced.

Two: a GitHub Release appears on the repository's Releases page, named for the tag, with the tarball attached as a downloadable asset. Replace the stub publish job with one that grants itself contents: write, downloads release-bundle with actions/download-artifact@v4, and creates the release. Running gh release create "$GITHUB_REF_NAME" ./app-*.tar.gz --generate-notes with GH_TOKEN set to secrets.GITHUB_TOKEN in the step's env: is the smallest thing that works.

Three: the production deploy does not run until a human approves it. Give deploy-production an environment: naming the protected environment you configured, and confirm the run genuinely stops in a Waiting for review state rather than sailing straight through.

Stretch goal once that works: make the release a draft when the tag contains a hyphen, as in v1.2.3-rc1, and a full release otherwise, so pre-release tags do not notify watchers.

One important caution

Rebuilding inside the deploy job instead of downloading the artifact. You then ship bytes that no job in the run ever tested.

Forgetting permissions: contents: write on the publish job. The default GITHUB_TOKEN is read-only, so creating the Release fails with a 403 even though every other step is correct.

GitHub Docs: Managing environments for deploymentCI/CD with GitHub Actions

Easy traps

  • Rebuilding inside the deploy job instead of downloading the artifact. You then ship bytes that no job in the run ever tested.
  • Forgetting permissions: contents: write on the publish job. The default GITHUB_TOKEN is read-only, so creating the Release fails with a 403 even though every other step is correct.
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

Finish the pipeline. You are done when pushing a single tag, with git tag v1.2.3 followed by git push origin v1.2.3, does all three of the following in that order from one workflow run.

One: the project is built exactly once. There must be no second npm run build anywhere downstream; every later job works from the release-bundle artifact that the build job produced.

Two: a GitHub Release appears on the repository's Releases page, named for the tag, with the tarball attached as a downloadable asset. Replace the stub publish job with one that grants itself contents: write, downloads release-bundle with actions/download-artifact@v4, and creates the release. Running gh release create "$GITHUB_REF_NAME" ./app-*.tar.gz --generate-notes with GH_TOKEN set to secrets.GITHUB_TOKEN in the step's env: is the smallest thing that works.

Three: the production deploy does not run until a human approves it. Give deploy-production an environment: naming the protected environment you configured, and confirm the run genuinely stops in a Waiting for review state rather than sailing straight through.

Stretch goal once that works: make the release a draft when the tag contains a hyphen, as in v1.2.3-rc1, and a full release otherwise, so pre-release tags do not notify watchers.

You'll know it worked when: As written, this scaffold is a working but deliberately incomplete pipeline. Pushing a tag that matches v* starts it. The build job is real and complete: it checks out the tagged commit, installs dependencies with npm ci, runs the production build, packages dist into a tarball named after the tag, and uploads it as an artifact called release-bundle with a thirty day retention. That much genuinely works. The other two jobs are stubs. The publish job waits for build and then only echoes a TODO message. It never downloads release-bundle, never creates a GitHub Release, and has no contents write permission, so no release object appears on the repository's Releases page. The deploy-production job waits for publish and echoes a second TODO. It has no environment block, so nothing pauses for approval and no environment-scoped secrets are available to it. Both stub jobs report success, which means the whole run finishes green while shipping nothing at all. That green is the trap: until you replace the echo steps, a passing run proves only that the build compiled.

Exercise: Build a Release Pipeline | Thuta Learning