Chapter 36Lesson 02~360 minutes

Capstone: Build a Secure Reusable Enterprise CI/CD Platform: Guided Hands-On Workflow

Assemble a disposable two-repository platform demo with a versioned reusable workflow, a justified custom policy action, immutable artifacts, optional GitHub attestation, a protected deployment simulation and recovery evidence.

Two repositoriesReusable CICustom actionAttestationRecovery

Learning objectives

  • Build a disposable two-repository platform/app demonstration.
  • Create a justified custom policy action and a versioned reusable CI workflow.
  • Pin all executable dependencies to exact full SHAs.
  • Promote one verified artifact through an environment-gated deployment simulation.
  • Preserve OIDC claims, attestation, incident and recovery evidence safely.

1. Guided scenario: two repositories, one bounded platform contract

Use two disposable public repositories under an account or test organization: gha-capstone-platform and gha-capstone-app. The platform repository will contain a reusable CI workflow plus one custom composite action that enforces a small organization-specific policy. The app repository will contain synthetic Python code, a caller workflow and the protected deployment intent. Public repositories keep standard hosted-runner use and artifact attestation available without paid infrastructure.

If creating GitHub repositories is undesirable, reproduce the same directory structure locally and run the policy/build/digest/deployment simulators directly. The local path faithfully teaches state separation but cannot create real GitHub run IDs, environment approvals, OIDC claims or attestations; record those limitations rather than inventing them.

2. Preflight and current assumptions

Assumption Capstone value Why explicit
Date checked 2026-09-10 GitHub Actions is continuously delivered.
Hosted runner ubuntu-24.04 Avoids mutable -latest label in the executable path.
Python 3.13 via pinned setup-python Makes the test/package toolchain explicit.
checkout v7.0.1 → 3d3c42e5aac5ba805825da76410c181273ba90b1 Immutable executable dependency.
setup-python v7.0.0 → 5fda3b95a4ea91299a34e894583c3862153e4b97 Immutable executable dependency.
upload-artifact v7.0.1 → 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a Artifact ID/digest producer.
download-artifact v8.0.1 → 3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c Artifact consumer by exact ID.
attest v4.2.2 → 1e69f48acb82d1966a394da916b4c1698aa569d6 Optional/public-repo provenance step.
Permissions deny by default; narrow per job Prevents ambient write authority.
Credentials no static cloud credential OIDC demonstrated as claims-only/optional provider boundary.
gh --version
git --version
gh auth status
# Confirm you are authenticated to the account that owns only the disposable lab repos.
# Do not continue if the target names already belong to valuable repositories.

3. Build one custom action where central policy logic is justified

A custom action is justified here because the organization wants one stable, versioned policy check callable from many repositories. It does not need repository write access, secrets or an external service. The action scans workflow text for a deliberately small deny-list and emits a digest of what it checked. That is a better custom-action boundary than hiding deployment credentials or an entire pipeline inside opaque code.

# gha-capstone-platform/.github/actions/policy-check/action.yml
name: capstone policy check
description: Fail closed on a small set of unsafe platform patterns.
inputs:
  workflow-root:
    description: Directory containing caller workflow files
    required: false
    default: .github/workflows
outputs:
  policy-digest:
    description: SHA-256 over inspected workflow text
    value: ${{ steps.check.outputs.policy-digest }}
runs:
  using: composite
  steps:
    - id: check
      shell: bash
      env:
        TARGET: ${{ inputs.workflow-root }}
      run: python3 "$GITHUB_ACTION_PATH/check.py" "$TARGET"
# gha-capstone-platform/.github/actions/policy-check/check.py
from pathlib import Path
import hashlib, sys
root = Path(sys.argv[1])
files = sorted(root.glob('*.y*ml'))
if not files:
    raise SystemExit('no workflow files found')
text = '
'.join(p.read_text(encoding='utf-8') for p in files)
# Teaching fixture only: production policy belongs in a real parser/policy engine.
forbidden = {
    'broad token permission': 'permissions: write-all',
    'privileged PR trigger': 'pull_request_target:',
    'implicit secret propagation': 'secrets: inherit',
}
violations = [name for name, token in forbidden.items() if token in text]
for line in text.splitlines():
    s=line.strip()
    if s.startswith('uses:') and '@' in s and not s.startswith('uses: ./'):
        ref=s.rsplit('@',1)[1].split()[0].strip('"'')
        if len(ref) != 40 or any(c not in '0123456789abcdefABCDEF' for c in ref):
            violations.append('mutable external action/workflow reference')
            break
digest=hashlib.sha256(text.encode()).hexdigest()
print(f'policy digest: {digest}')
with open(Path(__import__('os').environ['GITHUB_OUTPUT']), 'a', encoding='utf-8') as fh:
    fh.write(f'policy-digest={digest}
')
if violations:
    raise SystemExit('policy violations: ' + ', '.join(sorted(set(violations))))

Scope note: this text scanner is intentionally small so its behavior is inspectable. Do not advertise it as a complete enterprise policy engine. Lesson 3 shows where GitHub organization/enterprise policy, rulesets and runner-group controls belong.

4. Build the versioned reusable CI workflow

The reusable workflow separates test, package and attestation jobs. Test and package need only repository read access. Attestation is a distinct job with OIDC/attestation permissions and runs only when the caller requests it. This demonstrates an important platform rule: privileged capabilities should not be granted to every job just because one downstream step needs them.

# gha-capstone-platform/.github/workflows/reusable-ci.yml
name: reusable-capstone-ci
on:
  workflow_call:
    inputs:
      artifact-name:
        required: true
        type: string
      enable-attestation:
        required: false
        type: boolean
        default: false
    outputs:
      artifact-id:
        value: ${{ jobs.package.outputs.artifact-id }}
      artifact-digest:
        value: ${{ jobs.package.outputs.artifact-digest }}
      subject-sha256:
        value: ${{ jobs.package.outputs.subject-sha256 }}
permissions: {}
jobs:
  test:
    permissions:
      contents: read
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        python: ['3.12', '3.13']
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
        with:
          python-version: ${{ matrix.python }}
      - run: python -m unittest discover -s tests -v

  package:
    needs: test
    permissions:
      contents: read
    runs-on: ubuntu-24.04
    outputs:
      artifact-id: ${{ steps.upload.outputs.artifact-id }}
      artifact-digest: ${{ steps.upload.outputs.artifact-digest }}
      subject-sha256: ${{ steps.hash.outputs.sha }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Build deterministic release file
        run: |
          mkdir -p dist
          tar --sort=name --mtime='UTC 2020-01-01' --owner=0 --group=0 \
            --numeric-owner -czf dist/app.tgz app.py
      - id: hash
        run: echo "sha=$(sha256sum dist/app.tgz | awk '{print $1}')" >> "$GITHUB_OUTPUT"
      - id: upload
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: ${{ inputs.artifact-name }}
          path: dist/app.tgz
          if-no-files-found: error
          retention-days: 7

  attest:
    if: ${{ inputs.enable-attestation }}
    needs: package
    permissions:
      contents: read
      id-token: write
      attestations: write
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          artifact-ids: ${{ needs.package.outputs.artifact-id }}
          path: dist
      - run: echo "${{ needs.package.outputs.subject-sha256 }}  dist/app.tgz" | sha256sum -c -
      - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-path: dist/app.tgz

5. Commit the platform and capture the exact immutable revision

Do not guess a platform SHA in the caller. Commit the platform first, push it, then capture the full 40-character commit ID. The caller workflow is generated with that value literally embedded in both the reusable-workflow and custom-action references. A moving main reference would make two identical app commits execute different platform code.

cd gha-capstone-platform
git add .github
git commit -m "capstone platform v1"
git push origin HEAD:main
PLATFORM_SHA=$(git rev-parse HEAD)
printf '%s
' "$PLATFORM_SHA"
test "${#PLATFORM_SHA}" -eq 40

6. Create the synthetic application and caller workflow

The app is intentionally tiny so the lesson stays about delivery architecture. The caller runs the policy job first, calls reusable CI by immutable SHA second, and deploys only from a manual dispatch that explicitly requests deployment. Pull requests exercise policy and CI but cannot enter the deployment job.

# gha-capstone-app/app.py
def message():
    return "capstone-v1"
if __name__ == "__main__":
    print(message())

# gha-capstone-app/tests/test_app.py
import unittest
from app import message
class TestApp(unittest.TestCase):
    def test_message(self):
        self.assertEqual(message(), "capstone-v1")
# Generate this file after substituting your actual PLATFORM_SHA.
name: capstone-delivery
on:
  pull_request:
  workflow_dispatch:
    inputs:
      deploy:
        description: Deploy the verified artifact to the disposable target
        required: true
        type: boolean
        default: false
      inject-failure:
        description: Fail after the simulated target mutation
        required: true
        type: boolean
        default: false
permissions: {}

jobs:
  policy:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
    outputs:
      policy-digest: ${{ steps.policy.outputs.policy-digest }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      - id: policy
        uses: OWNER/gha-capstone-platform/.github/actions/policy-check@PLATFORM_SHA

  ci:
    needs: policy
    uses: OWNER/gha-capstone-platform/.github/workflows/reusable-ci.yml@PLATFORM_SHA
    with:
      artifact-name: app-${{ github.sha }}
      enable-attestation: ${{ github.event_name == 'workflow_dispatch' }}
    permissions:
      contents: read
      id-token: write
      attestations: write

  deploy:
    if: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy }}
    needs: ci
    runs-on: ubuntu-24.04
    environment: capstone-production
    concurrency:
      group: capstone-production
      cancel-in-progress: false
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
        with:
          artifact-ids: ${{ needs.ci.outputs.artifact-id }}
          path: dist
      - name: Verify exact release bytes
        run: echo "${{ needs.ci.outputs.subject-sha256 }}  dist/app.tgz" | sha256sum -c -
      - name: Inspect OIDC claims without printing the bearer token
        shell: bash
        run: |
          set -euo pipefail
          response=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN"             "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=capstone.local")
          printf '%s' "$response" | python3 .github/scripts/print_oidc_claims.py
          unset response
      - name: Promote exact bytes to disposable target
        run: |
          mkdir -p /tmp/capstone-target
          cp dist/app.tgz /tmp/capstone-target/app.tgz
          sha256sum /tmp/capstone-target/app.tgz
      - name: Controlled incident injection
        if: ${{ inputs.inject-failure }}
        run: |
          printf '%s
' "run=${GITHUB_RUN_ID}" "attempt=${GITHUB_RUN_ATTEMPT}"             "sha=${GITHUB_SHA}" "artifact=${{ needs.ci.outputs.subject-sha256 }}" > deployment-first-failure.txt
          exit 42
      - name: Verify simulated target health
        run: tar -tzf /tmp/capstone-target/app.tgz | grep -Fx app.py
      - name: Write deployment evidence
        if: ${{ always() }}
        run: |
          printf '%s
' "run=${GITHUB_RUN_ID}" "attempt=${GITHUB_RUN_ATTEMPT}"             "sha=${GITHUB_SHA}" "artifact=${{ needs.ci.outputs.subject-sha256 }}"             "artifact_id=${{ needs.ci.outputs.artifact-id }}" > deployment-evidence.txt
      - name: Preserve non-secret deployment evidence
        if: ${{ always() }}
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
        with:
          name: deployment-evidence-${{ github.run_id }}-${{ github.run_attempt }}
          path: |
            deployment-evidence.txt
            deployment-first-failure.txt
          if-no-files-found: ignore

The GitHub-owned actions above already use the verified full SHAs listed in the preflight table. Replace only OWNER and PLATFORM_SHA with your disposable account/organization and the exact platform commit you just captured. The committed workflow must contain the literal 40-character platform SHA.

7. Inspect OIDC claims safely

The deploy job requests a short-lived OIDC token only after the environment boundary is entered. The helper reads the response from standard input, decodes the JWT payload in memory and prints an allowlist of non-secret claims. It never prints the bearer token. For repositories created after July 15, 2026, expect the default sub to include immutable owner/repository IDs; inspect the actual claim rather than hard-coding an old format.

# gha-capstone-app/.github/scripts/print_oidc_claims.py
import base64, json, sys
response=json.load(sys.stdin)
token=response['value']
part=token.split('.')[1]
part += '=' * (-len(part) % 4)
claims=json.loads(base64.urlsafe_b64decode(part.encode()))
allow=['iss','aud','sub','repository','repository_id','repository_owner_id',
       'ref','sha','environment','job_workflow_ref','runner_environment']
print(json.dumps({k:claims.get(k) for k in allow if k in claims}, indent=2))

Security note: the OIDC token is a real short-lived bearer credential. Never echo the raw response, run the step with shell tracing, upload the token, or persist it as an artifact. Decoding selected claims is evidence; publishing the token is credential exposure.

8. Add the deployment guard and run the progression

Create the capstone-production environment in the disposable app repository. On a public repository, optionally configure a required reviewer and prevent self-review. Then run three bounded scenarios: a pull request, a successful manual deployment, and an incident-injection deployment. Preserve each run URL, ID, attempt, source SHA, policy digest, artifact outputs and environment/deployment record before recovery.

  1. PR: policy + reusable CI run; deployment is absent by design.
  2. Dispatch A: deploy=true, inject-failure=false; CI produces/attests bytes, environment authorizes deploy, exact digest is verified, simulated health succeeds.
  3. Dispatch B: deploy=true, inject-failure=true; target copy happens, then the controlled step fails. Preserve attempt evidence before any rerun/new run.
  4. Recovery: because dispatch inputs are part of the original run, a rerun repeats inject-failure=true. Start a new dispatch with the same source ref and inject-failure=false, then reconcile the two run IDs explicitly.

9. Challenge: choose the correct boundary

A team asks to put cloud credentials inside the platform custom action so every repository can “deploy with one step.” Reject the convenience framing. Decide which layer owns authorization: the consumer environment/deployment job should obtain a short-lived OIDC identity for its declared target; the custom action may implement mechanics but should not become an invisible credential vault. State the expected permissions, OIDC audience/subject constraints and target readback evidence before writing code.

10. Cleanup and evidence preservation

Delete only the disposable repositories after downloading the evidence dossier you intend to retain. If you configured a required reviewer or any test secret, remove it first and confirm the repository names. The local-only path should remove temporary directories under its own lab root. Never copy this cleanup pattern onto a production organization without exact resource guards.

Next lesson

Capstone: Build a Secure Reusable Enterprise CI/CD Platform: Configuration, Design Patterns, and Trade-Offs

Continue with the next lesson to build on the current concepts, evidence, security boundaries, and operational practices.

Knowledge check

Why is the policy checker a reasonable custom action boundary?

Why must the platform SHA be captured before generating the caller workflow?

Why does the deploy job download by artifact ID and verify the file SHA again?

Why is a failed incident-injection rerun not repaired by changing the dispatch input?

Which data from the OIDC response may the lab print?

Official references and version notes

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.