Chapter 32Lesson 02~330 minutes

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.

Disposable labActionsCodeQLAttestationsRelease

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.

Safety boundary: do not run the ruleset, secret, release, or cleanup exercises against a valuable repository. No PAT, App private key, cloud key, production secret, self-hosted runner registration token, or package credential is created.
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"
Production difference: a solo learner cannot supply independent approval. In a team repository, use distinct reviewer identities and, where appropriate, CODEOWNERS/review requirements. Do not call a self-review “separation of duties.”

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.

The source program is intentionally harmless. A clean scan proves that analysis ran; it does not prove the program is secure. False negatives exist, query coverage is bounded, and manual design review remains necessary.

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"
Identity rule: do not silently move a released tag to “fix” provenance. If a release is wrong, preserve evidence, stop promotion, create a corrected source commit/artifact, and use a new release identity according to your release policy.

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-runners plus signer workflow/source constraints.
  • Dependency update policy → .github/dependabot.yml schedule/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?

Why bootstrap one successful verify run before requiring that check in a ruleset?

What does sha256sum -c prove that the release tag does not?

Why is the dummy secret push canceled rather than bypassed?

Does the evidence manifest prove that the controls were effective?

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.

Next lesson

Production Capstone: Govern a GitHub Organization and Build a Secure End-to-End Delivery System: Security, Governance, and Reliability Validation

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.

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