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.
~90 minutes hands-on · Core tier (maps to module M2). $0 — everything runs with bash + python3; act + Docker optional.
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."
.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.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?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.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.PARITY FAIL: artifact manifests differ. A harness you have never seen fail proves nothing. Restore.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.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.
bash ci/parity.sh out-legacy out-migrated prints PARITY OK and exits 0.PARITY FAIL — harness sensitivity proven.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.pipemig/ repo with the migrated workflow, both output dirs, and parity-report.md. Self-grading rubric (pass = 8/10):
H) = 2.act fails on schedule/secrets: normal — run with act push semantics or use the manual transcription path; the harness only cares about produced files.src/*.py, not src/ recursively, and that the loop sorts filenames — ordering is part of determinism.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.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.
The five seeded migration traps:
post { always { archiveArtifacts } }needs if: always() or failed tests lose their logs.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.