BXUP Track progress & test yourself — free
DevOps / SRE / Platform · Hands-on lab · ~4 min read
DEVOPS / SRE / PLATFORM · HANDS-ON LAB

Lab 04 · Pipeline Migration With a Parity Harness

Migrate a Jenkinsfile to GitHub Actions using an AI assistant — and prove the migration is faithful with an artifact-comparison harness instead of eyeballing YAML. Transpilation is where assistants shine on syntax and quietly drop semantics: post-blocks, branch gates, trigger schedules, timeout behavior.

The harness converts "looks right" into "produces byte-identical build outputs," which is the only parity definition that survives a cutover meeting.

Time & tier

~90 minutes hands-on · Core tier (maps to module M2). $0 — everything runs with bash + python3; act + Docker optional.

Setup

Create pipemig/ with this layout: src/, tests/, ci/, .github/workflows/.

# src/pricing.py
def total(items, gst_rate=0.18):
    subtotal = sum(i["price"] * i["qty"] for i in items)
    return round(subtotal * (1 + gst_rate), 2)
# tests/test_pricing.py
import unittest, sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from pricing import total

class T(unittest.TestCase):
    def test_basic(self):
        self.assertEqual(total([{"price": 100, "qty": 2}]), 236.0)
    def test_empty(self):
        self.assertEqual(total([]), 0)

if __name__ == "__main__":
    unittest.main()

The legacy pipeline you must migrate:

// Jenkinsfile
pipeline {
  agent any
  triggers { cron('H 2 * * 1-5') }
  options { timeout(time: 15, unit: 'MINUTES') }
  environment { APP_ENV = 'ci' }
  stages {
    stage('Lint') {
      steps { sh 'python3 -m compileall -q src && touch out/lint.ok' }
    }
    stage('Test') {
      steps { sh 'python3 -m unittest discover -s tests -v 2> out/test.log' }
      post { always { archiveArtifacts artifacts: 'out/test.log' } }
    }
    stage('Package') {
      steps {
        sh 'mkdir -p out/pkg && cp src/*.py out/pkg/'
        sh 'cd out/pkg && for f in $(ls | sort); do shasum -a 256 "$f"; done > ../manifest.txt'
      }
    }
    stage('Deploy') {
      when { branch 'main' }
      steps {
        withCredentials([string(credentialsId: 'deploy-token', variable: 'DEPLOY_TOKEN')]) {
          sh './ci/deploy.sh'
        }
      }
    }
  }
}

The canonical step script (ground truth for what a correct pipeline produces) and the parity harness:

# ci/steps.sh — canonical build steps; any faithful pipeline must reproduce out/
#!/usr/bin/env bash
set -euo pipefail
OUT="${1:?usage: steps.sh <outdir>}"
rm -rf "$OUT" && mkdir -p "$OUT"
python3 -m compileall -q src && touch "$OUT/lint.ok"
python3 -m unittest discover -s tests -v 2> "$OUT/test.log" || { cat "$OUT/test.log"; exit 1; }
grep -c "ok$" "$OUT/test.log" > "$OUT/test.count"
mkdir -p "$OUT/pkg" && cp src/*.py "$OUT/pkg/"
( cd "$OUT/pkg" && for f in $(ls | sort); do shasum -a 256 "$f"; done ) > "$OUT/manifest.txt"
# ci/parity.sh — compares two pipeline output dirs; non-zero exit on divergence
#!/usr/bin/env bash
set -uo pipefail
A="${1:?legacy outdir}" B="${2:?migrated outdir}" FAIL=0
for f in lint.ok test.count manifest.txt; do
  if [ ! -e "$A/$f" ]; then echo "PARITY FAIL: $f missing in $A"; FAIL=1; fi
  if [ ! -e "$B/$f" ]; then echo "PARITY FAIL: $f missing in $B"; FAIL=1; fi
done
diff -q "$A/manifest.txt" "$B/manifest.txt" >/dev/null 2>&1 || { echo "PARITY FAIL: artifact manifests differ"; FAIL=1; }
diff -q "$A/test.count" "$B/test.count" >/dev/null 2>&1 || { echo "PARITY FAIL: test counts differ"; FAIL=1; }
[ "$FAIL" -eq 0 ] && echo "PARITY OK: lint marker, test count and artifact manifest all match."
exit $FAIL

chmod +x ci/*.sh. Baseline: bash ci/steps.sh out-legacy — this stands in for "what Jenkins produced last night."

Guided steps

  1. Migrate with AI. Prompt: "Convert this Jenkinsfile to a GitHub Actions workflow at .github/workflows/build.yml. Preserve ALL semantics: schedule, timeout, environment, per-stage behavior, the post-always artifact archiving, the branch-gated deploy, and the credential. Explain each mapping in a table." Save the mapping table — it is half your artifact.
  2. Desk-check against the five known traps before running anything. Score the AI's draft: (a) did the post { always } become if: always() on the upload step, or will a failed test skip log archiving? (b) did cron('H 2 * * 1-5') survive — Actions has no H hash syntax, so a literal copy is invalid; the honest mapping picks a concrete minute and notes the load-spreading semantics are lost; (c) did when { branch 'main' } become a correct if: github.ref == 'refs/heads/main' (not a paths filter, not github.head_ref); (d) did withCredentials become ${{ secrets.DEPLOY_TOKEN }} scoped to the step — not echoed, not job-wide; (e) did options { timeout } become timeout-minutes: 15 on the job?
  3. Execute the migrated steps. With Docker: act -j build. Without: copy each run: command from your workflow, in order, into migrate-run.sh with OUT=out-migrated, and execute it. This manual transcription is itself a review pass — you will feel any step the AI dropped.
  4. Run parity.bash ci/parity.sh out-legacy out-migrated. Any PARITY FAIL line means the migration lost a semantic. Feed the exact failure line back to the assistant, patch, re-run.
  5. Prove the harness can fail. Delete the Package step from your workflow, re-run steps 3–4, and confirm PARITY FAIL: artifact manifests differ. A harness you have never seen fail proves nothing. Restore.
  6. Write the parity report (parity-report.md): mapping table, trap scorecard from step 2 (which the AI got right/wrong on first draft), final PARITY OK output, and the residual-risk list — things the harness does not cover (schedule drift, timeout behavior, secret scoping) with how you'd verify each post-cutover.
Key Takeaway:

Migration parity has two layers — artifact parity (harness-provable now) and behavioral parity (triggers, timeouts, permissions — only provable in the target system). Ship the first as evidence; list the second as explicit cutover risk.

Verification

  • bash ci/parity.sh out-legacy out-migrated prints PARITY OK and exits 0.
  • The sabotage run (step 5) produced a recorded PARITY FAIL — harness sensitivity proven.
  • Your workflow contains: schedule: with concrete cron, timeout-minutes: 15, if: always() on the test-log upload, if: github.ref == 'refs/heads/main' on deploy, and a step-scoped secret.
  • Trap scorecard filled for all five, with the AI's first-draft verdict per trap.

Completion artifact

pipemig/ repo with the migrated workflow, both output dirs, and parity-report.md. Self-grading rubric (pass = 8/10):

  • Artifact parity (0–3): PARITY OK achieved and sabotage-test recorded = 3.
  • Trap detection (0–3): all five semantic traps scored against the AI's first draft = 3.
  • Mapping literacy (0–2): table explains each Jenkins→Actions construct, including what was lost (cron H) = 2.
  • Residual risk (0–2): behavioral-parity gaps listed with a post-cutover verification plan = 2.

Stuck?

  • act fails on schedule/secrets: normal — run with act push semantics or use the manual transcription path; the harness only cares about produced files.
  • Manifest mismatch with identical steps: check you copied src/*.py, not src/ recursively, and that the loop sorts filenames — ordering is part of determinism.
  • Assistant insists H 2 * * 1-5 is valid Actions cron: it is not; H is Jenkins-only load-hashing. This is a good doubling-down specimen for your report.

Client-safe variant

Real client Jenkinsfiles embed internal repo URLs, credential IDs, and agent labels. Redact those into placeholders before prompting a public model, or use the client-tenancy assistant. The parity harness pattern is the client-safe core: it runs entirely inside the client environment, uses no LLM, and produces exactly the cutover evidence a change-advisory board asks for.

Answer key (don't read before attempting)

The five seeded migration traps:

  • post { always { archiveArtifacts } }needs if: always() or failed tests lose their logs.
  • Jenkins cron('H 2 * * 1-5')H does not exist in Actions; pick a minute and document the lost load-spreading.
  • when { branch 'main' }becomes if: github.ref == 'refs/heads/main'; drafts using github.head_ref break on push events.
  • withCredentialsbecomes step-level env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} after creating the repo secret.
  • options { timeout }becomes timeout-minutes: 15 at job level.

Typical first-draft AI score is 3/5 — the cron and always() traps are the usual casualties.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in