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.
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.
- PR: policy + reusable CI run; deployment is absent by design.
-
Dispatch A:
deploy=true,inject-failure=false; CI produces/attests bytes, environment authorizes deploy, exact digest is verified, simulated health succeeds. -
Dispatch B:
deploy=true,inject-failure=true; target copy happens, then the controlled step fails. Preserve attempt evidence before any rerun/new run. -
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 andinject-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.
Knowledge check
Why is the policy checker a reasonable custom action boundary?
It encapsulates small organization-specific, read-only logic that many repositories can reuse without hiding credentials or an entire pipeline.
Why must the platform SHA be captured before generating the caller workflow?
Because the caller should execute one exact reviewed platform revision; a branch or tag could move.
Why does the deploy job download by artifact ID and verify the file SHA again?
The artifact ID selects the exact GitHub artifact record, while the file SHA proves the downloaded subject bytes match the CI output.
Why is a failed incident-injection rerun not repaired by changing the dispatch input?
Reruns repeat the original run/event identity and inputs. A changed input requires a new workflow dispatch and therefore a new run ID.
Which data from the OIDC response may the lab print?
Only an allowlist of decoded non-secret claims such as issuer, audience, subject, repository IDs, ref and environment; never the raw JWT.
Official references and version notes
- Workflow syntax for GitHub Actions — Current workflow/job/permissions/runner syntax and hosted-runner behavior.
- Reusing workflow configurations — Current reusable-workflow access, nesting and call-tree limits.
- Secure use reference — Least privilege, untrusted-input handling and full-SHA dependency guidance.
- Deployments and environments — Environment approvals, secrets, protection rules and deployment boundaries.
- OpenID Connect reference — OIDC claim semantics including immutable subject claims introduced in 2026.
- Artifact attestations — Provenance model and verification expectations.
- Using artifact attestations — Current permissions and actions/attest workflow pattern.
- GitHub-hosted runners reference — Current runner labels, images, hardware and billing boundaries.
- Runner groups — Runner-group trust boundary and access-control model.
- GitHub Actions billing and usage — Current public/private hosted-runner and usage accounting model.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.