Production Capstone: Govern a GitHub Organization and Build a Secure End-to-End Delivery System: Implementation and Automation Build-Out
Build the disposable capstone from issue through pull request, least-privilege Actions, public security controls, deterministic artifacts, provenance, release evidence, and API reporting with before/after verification.
Learning objectives
- Create a disposable public repository and move a change from issue to PR to verified merge under an explicit ruleset.
- Implement a deny-by-default Actions workflow whose PR job is read-only and whose release job alone can create artifact provenance.
- Exercise dependency automation, public CodeQL, safe push protection, deterministic artifact hashing, attestation verification, and release identity.
- Capture structured REST/gh/Git/workflow evidence before and after state changes, using the current REST API version header.
- Finish with an honest readiness review that names solo-lab, organization, paid-feature, and external-system gaps.
1. Disposable implementation scope and preflight
Use a repository created solely for this capstone. The commands below are written for Bash/Git Bash. PowerShell users can run them from Git Bash or translate variable assignment/heredoc syntax while preserving the GitHub operations.
gh auth status
OWNER="$(gh api -H "X-GitHub-Api-Version: 2026-03-10" user --jq .login)"
REPO="github-production-capstone"
FULL="$OWNER/$REPO"
# Preflight: prove the target does not already exist before creating it.
if gh repo view "$FULL" >/dev/null 2>&1; then
echo "Stop: $FULL already exists. Use a different disposable name."
exit 1
fi
gh repo create "$FULL" --public --clone --description "Disposable GitHub production-capstone lab"
cd "$REPO"
printf '# GitHub production capstone\n' > README.md
mkdir -p src docs .github/workflows .github
cat > src/app.js <<'JS'
function add(a, b) { return a + b; }
if (add(2, 3) !== 5) throw new Error('self-check failed');
console.log('capstone-ok');
JS
cat > package.json <<'JSON'
{
"name": "github-production-capstone",
"version": "0.1.0",
"private": true,
"scripts": {"test": "node src/app.js"},
"dependencies": {"is-number": "6.0.0"}
}
JSON
npm install --ignore-scripts --package-lock-only
cat > docs/index.md <<'MD'
# Production capstone
This repository is disposable training infrastructure. Every release must map to an exact source commit, digest, and provenance record.
MD
git add .
git commit -m "Initialize disposable production capstone"
git push -u origin main
BASE_SHA="$(git rev-parse HEAD)"
printf 'baseline_sha=%s\n' "$BASE_SHA"
The resources changed are explicit: one public repository, one
initial Git commit on main, and ordinary repository
files. No Actions write authority has been granted yet. Capture
BASE_SHA; later evidence must distinguish this baseline
from release commits.
2. Create change intent before implementation
ISSUE_URL="$(gh issue create -R "$FULL" --title "Capstone: require evidence-backed delivery" --body $'Acceptance criteria:
- pull requests run verify
- release artifact has SHA-256
- release artifact is attested
- API report records source and release identity')"
echo "$ISSUE_URL"
gh issue list -R "$FULL" --state open --json number,title,url
The issue is a hosted GitHub object, not a Git ref. It records why the change exists and gives the later PR a stable requirement to link. The API/CLI evidence above is read-only after the create operation.
3. Add a least-privilege delivery workflow
Save the following complete workflow as
.github/workflows/capstone.yml. The top-level empty
permission map is a deliberate deny-by-default posture. The
PR-facing verify job gets only
contents: read. The manual release job separately gets
attestation/OIDC authority, and only when the event is
workflow_dispatch on main.
name: capstone-delivery
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions: {}
concurrency:
group: capstone-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: verify
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout reviewed commit
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Static checks and deterministic build
shell: bash
run: |
set -euo pipefail
test -f src/app.js
node --check src/app.js
rm -rf dist
mkdir -p dist
cp src/app.js dist/app.js
printf '%s\n' "${{ github.sha }}" > dist/source-sha.txt
tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \
-cf - dist | gzip -n > capstone.tar.gz
sha256sum capstone.tar.gz | tee capstone.tar.gz.sha256
release-evidence:
name: release-evidence
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
needs: verify
runs-on: ubuntu-latest
permissions:
contents: read
attestations: write
id-token: write
steps:
- name: Checkout release commit
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Rebuild exact artifact
shell: bash
run: |
set -euo pipefail
rm -rf dist
mkdir -p dist
cp src/app.js dist/app.js
printf '%s\n' "${{ github.sha }}" > dist/source-sha.txt
tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \
-cf - dist | gzip -n > capstone.tar.gz
sha256sum capstone.tar.gz | tee capstone.tar.gz.sha256
- name: Upload artifact evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: capstone-${{ github.sha }}
path: |
capstone.tar.gz
capstone.tar.gz.sha256
retention-days: 7
if-no-files-found: error
- name: Attest release bytes
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: capstone.tar.gz
cat > .github/workflows/capstone.yml <<'YAML'
name: capstone-delivery
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions: {}
concurrency:
group: capstone-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: verify
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout reviewed commit
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Static checks and deterministic build
shell: bash
run: |
set -euo pipefail
test -f src/app.js
node --check src/app.js
rm -rf dist
mkdir -p dist
cp src/app.js dist/app.js
printf '%s\n' "${{ github.sha }}" > dist/source-sha.txt
tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \
-cf - dist | gzip -n > capstone.tar.gz
sha256sum capstone.tar.gz | tee capstone.tar.gz.sha256
release-evidence:
name: release-evidence
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
needs: verify
runs-on: ubuntu-latest
permissions:
contents: read
attestations: write
id-token: write
steps:
- name: Checkout release commit
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Rebuild exact artifact
shell: bash
run: |
set -euo pipefail
rm -rf dist
mkdir -p dist
cp src/app.js dist/app.js
printf '%s\n' "${{ github.sha }}" > dist/source-sha.txt
tar --sort=name --mtime='UTC 1970-01-01' --owner=0 --group=0 --numeric-owner \
-cf - dist | gzip -n > capstone.tar.gz
sha256sum capstone.tar.gz | tee capstone.tar.gz.sha256
- name: Upload artifact evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: capstone-${{ github.sha }}
path: |
capstone.tar.gz
capstone.tar.gz.sha256
retention-days: 7
if-no-files-found: error
- name: Attest release bytes
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
with:
subject-path: capstone.tar.gz
YAML
git checkout -b feature/capstone-controls
git add .github/workflows/capstone.yml
git commit -m "Add least-privilege capstone delivery workflow"
git push -u origin feature/capstone-controls
PR_URL="$(gh pr create -R "$FULL" --base main --head feature/capstone-controls --title "Add evidence-backed delivery controls" --body "Implements the capstone delivery control. Closes the capstone issue after merge.")"
echo "$PR_URL"
gh pr view -R "$FULL" --json number,headRefName,headRefOid,baseRefName,state,statusCheckRollup
The PR event runs only verify. The release job is
skipped because its condition is false. That is a privilege
boundary: unmerged PR code does not receive
id-token: write or attestations: write.
The checkout action also avoids persisting credentials in Git
configuration.
4. Establish the required-check identity before making it required
GitHub requires a required status check to have run successfully in the repository recently before it can be selected. Wait for the PR check, inspect the exact check name, then merge this bootstrap PR before adding the ruleset. This is the one intentional bootstrap window; record it in the evidence report.
gh pr checks -R "$FULL" --watch
PR_NUMBER="$(gh pr view -R "$FULL" --json number --jq .number)"
PR_HEAD="$(gh pr view "$PR_NUMBER" -R "$FULL" --json headRefOid --jq .headRefOid)"
printf 'pr=%s head=%s\n' "$PR_NUMBER" "$PR_HEAD"
# Solo training lab: inspect the complete diff before merging.
gh pr diff "$PR_NUMBER" -R "$FULL"
gh pr merge "$PR_NUMBER" -R "$FULL" --squash --delete-branch
git checkout main
git pull --ff-only
CONTROL_SHA="$(git rev-parse HEAD)"
printf 'control_sha=%s\n' "$CONTROL_SHA"
5. Add the repository ruleset with read-before-write inspection
First inspect current rulesets. Then, in the disposable repository
UI, open
Settings → Rules → Rulesets → New branch ruleset.
Name it capstone-main, set enforcement to
Active, target the default branch, require a pull
request before merging, and require status check
verify. Do not enable bypass actors for the mandatory
exercise. Save only after confirming the target repository and
branch.
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$FULL/rulesets" --jq '.[] | {id,name,enforcement,target}'
# After saving in the UI, verify hosted state again.
RULESET_ID="$(gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$FULL/rulesets" --jq '.[] | select(.name=="capstone-main") | .id')"
test -n "$RULESET_ID"
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$FULL/rulesets/$RULESET_ID" --jq '{id,name,enforcement,target,conditions,rules,bypass_actors}' > ruleset-evidence.json
cat ruleset-evidence.json
This is the first true prevention gate in the disposable lab. A
normal direct update to main should no longer be the
route for changes. Do not “test” enforcement with a destructive
force push; open another branch and PR instead.
6. Add dependency automation without seeding a known vulnerability
mkdir -p .github
cat > .github/dependabot.yml <<'YAML'
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 3
labels:
- dependencies
YAML
git checkout -b feature/dependency-policy
git add .github/dependabot.yml package.json package-lock.json
git commit -m "Add bounded dependency update policy"
git push -u origin feature/dependency-policy
gh pr create -R "$FULL" --base main --head feature/dependency-policy --title "Add bounded dependency update policy" --body "Weekly updates, limited PR concurrency; no deliberately vulnerable dependency."
gh pr checks -R "$FULL" --watch
The fixture uses an outdated package to create an update opportunity, not a deliberately vulnerable package. Dependabot-generated PRs remain changes that need tests and review; automation identity does not remove review responsibility.
7. Enable one real public security-analysis path
In the disposable repository, open Settings → Security → Advanced Security (wording can evolve), locate code scanning/CodeQL, and enable Default setup for the JavaScript repository. Before enabling, record the current state. After enabling, verify the CodeQL configuration and first analysis in Security → Code scanning.
8. Exercise secret prevention with a non-authenticating dummy value
GitHub publishes a dedicated dummy value for learning push protection. It cannot authenticate to a real service. Use it only in the disposable repository and cancel the blocked change instead of bypassing protection.
git checkout -b exercise/push-protection
printf 'TRAINING_ONLY=secret_scanning_EXAMPLE_DUMMY_TOKEN_NOT_REAL_12345\n' > training-secret.txt
git add training-secret.txt
git commit -m "Exercise push protection with GitHub dummy value"
# Expected on an eligible public repository: push protection blocks this push.
set +e
git push -u origin exercise/push-protection
PUSH_RC=$?
set -e
printf 'push_exit=%s\n' "$PUSH_RC"
# Cancel the exercise locally; never bypass the protection.
git reset --hard HEAD~1
rm -f training-secret.txt
git checkout main
git branch -D exercise/push-protection
test "$PUSH_RC" -ne 0 || echo "No block observed; inspect repository/user push-protection settings before proceeding."
If the push is blocked, the causal evidence is the rejection plus the absence of the commit on the remote branch. If a real secret were involved, the sequence would be different: revoke/rotate immediately, investigate use, migrate consumers, remove exposed surfaces, and only then decide whether history rewriting is justified.
9. Produce and verify artifact identity and provenance
After the dependency-policy PR (and any other planned source change)
is merged under the ruleset, update local main. Trigger
the release-evidence workflow manually on main. The
artifact name contains the exact source SHA.
git checkout main
git pull --ff-only
RELEASE_SHA="$(git rev-parse HEAD)"
gh workflow run capstone.yml -R "$FULL" --ref main
sleep 3
RUN_ID="$(gh run list -R "$FULL" --workflow capstone.yml --event workflow_dispatch --json databaseId,headSha,status,createdAt --jq --arg sha "$RELEASE_SHA" '[.[] | select(.headSha==$sha)] | sort_by(.createdAt) | reverse | .[0].databaseId')"
test -n "$RUN_ID"
gh run watch "$RUN_ID" -R "$FULL" --exit-status
gh run view "$RUN_ID" -R "$FULL" --json databaseId,event,headSha,conclusion,jobs
rm -rf release-download && mkdir release-download
gh run download "$RUN_ID" -R "$FULL" -n "capstone-$RELEASE_SHA" -D release-download
cd release-download
sha256sum -c capstone.tar.gz.sha256
ASSET_SHA256="$(sha256sum capstone.tar.gz | awk '{print $1}')"
printf 'source=%s\nartifact_sha256=%s\n' "$RELEASE_SHA" "$ASSET_SHA256"
# Consumer-side policy: exact repository, workflow, source ref, and hosted-runner requirement.
gh attestation verify capstone.tar.gz --repo "$FULL" --signer-workflow "$FULL/.github/workflows/capstone.yml" --source-ref refs/heads/main --deny-self-hosted-runners --format json > attestation-verification.json
cd ..
A successful check now has two independent forms of identity: the
local SHA-256 file verifies the downloaded bytes, while
gh attestation verify verifies signed provenance under
explicit trust constraints. Neither depends on trusting an artifact
filename.
10. Create a disposable GitHub Release and prove its source target
TAG="v0.1.0-capstone"
gh release create "$TAG" release-download/capstone.tar.gz release-download/capstone.tar.gz.sha256 -R "$FULL" --target "$RELEASE_SHA" --title "Disposable capstone $TAG" --notes "Training-only release. Source SHA: $RELEASE_SHA"
gh release view "$TAG" -R "$FULL" --json tagName,targetCommitish,url,assets
TAG_SHA="$(gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$FULL/git/ref/tags/$TAG" --jq .object.sha)"
printf 'release_sha=%s expected=%s\n' "$TAG_SHA" "$RELEASE_SHA"
test "$TAG_SHA" = "$RELEASE_SHA"
11. Build a read-only API/evidence report
mkdir -p handoff/evidence
cp ruleset-evidence.json handoff/evidence/
cp release-download/attestation-verification.json handoff/evidence/
gh repo view "$FULL" --json nameWithOwner,visibility,defaultBranchRef,url > handoff/evidence/repository.json
gh pr list -R "$FULL" --state all --limit 100 --json number,title,state,headRefOid,mergeCommit,url > handoff/evidence/pull-requests.json
gh api -H "X-GitHub-Api-Version: 2026-03-10" "repos/$FULL/actions/runs?per_page=100" --jq '{total_count,workflow_runs:[.workflow_runs[]|{id,event,head_sha,status,conclusion,created_at}]}' > handoff/evidence/workflow-runs.json
gh release view "$TAG" -R "$FULL" --json tagName,targetCommitish,url,assets > handoff/evidence/release.json
python - <<'PY'
from pathlib import Path
import hashlib, json
root=Path('handoff/evidence')
rows=[]
for p in sorted(root.glob('*')):
if p.is_file():
rows.append({'file':p.name,'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'bytes':p.stat().st_size})
Path('handoff/evidence-manifest.json').write_text(json.dumps(rows,indent=2)+'\n')
PY
cat handoff/evidence-manifest.json
This is evidence production, not “compliance achieved.” The report records repository state, PRs, workflow runs, ruleset state, release metadata, and attestation verification. In an eligible organization, add time-bounded audit-log evidence; in the free personal lab, do not fabricate an enterprise audit API response.
12. Challenge: choose the control, not the click path
Your release manager asks for three changes: (1) external consumers
must reject artifacts built on self-hosted runners; (2) dependency
updates should remain bounded to avoid PR floods; (3) emergency
changes must not disable the normal verify check
permanently. For each request, identify the
surface and evidence you would use before changing
anything.
-
Consumer provenance policy →
gh attestation verify --deny-self-hosted-runnersplus signer workflow/source constraints. -
Dependency update policy →
.github/dependabot.ymlschedule/grouping/open-PR limits plus PR inventory. - Emergency governance → ruleset/required-check evidence plus a documented, time-bounded emergency procedure; restoration is part of the procedure.
13. Readiness review before Lesson 3
| Item | Mandatory status | Residual assumption |
|---|---|---|
| Repository | Disposable public repo only. | No real production data. |
| Review separation | Not proven in solo lab. | Requires multiple maintainers/team model. |
| CI | Hosted runner; job-scoped least privilege. | Third-party/external network dependencies still need production policy. |
| Security | Public CodeQL + secret-protection exercise + dependency update policy. | Private/internal advanced security requires eligible plan/product. |
| Provenance | Real GitHub attestation on exact artifact. | External deployment must add its own verification gate. |
| Audit | Git/API/workflow evidence. | Enterprise audit API/streaming is optional and plan/role dependent. |
| Packages | Not required. | GHCR/package promotion can be added without changing the identity model. |
Knowledge checks
Why does the PR job not receive
id-token: write?
The PR only needs to validate source. OIDC/attestation authority belongs to the trusted release job. Keeping it out of PR execution reduces the authority exposed to unmerged code.
Why bootstrap one successful verify run before
requiring that check in a ruleset?
GitHub requires the status check to have completed successfully in the repository recently before it can be selected as a required check. The bootstrap is recorded, then the policy is enabled.
What does sha256sum -c prove that the release tag
does not?
It proves the local downloaded bytes match the recorded digest. A tag identifies a Git object; it does not by itself authenticate artifact bytes.
Why is the dummy secret push canceled rather than bypassed?
The lesson is testing prevention, not exception handling. Routine bypass would normalize the unsafe path and create noisy/meaningless alerts.
Does the evidence manifest prove that the controls were effective?
It proves which evidence files were captured and whether they later changed. Effectiveness still requires interpreting those files against the control objective and independent state.
Summary
You now have a free-compatible delivery system with explicit change intent, a stable required check, deny-by-default workflow permissions, real public security analysis, safe secret-prevention practice, deterministic artifact identity, GitHub provenance, a release tied to one source SHA, and a normalized evidence packet. Lesson 3 validates the design as a platform rather than trusting the implementation because it ran once.
Official references
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.