diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 21c69ac..2400030 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,3 +25,8 @@ - [ ] Root commands still work, or the PR explains the migration path. - [ ] No root npm dependencies were added. - [ ] Generated files are ignored or intentionally committed. + +## Sign-off + +- [ ] Every commit is signed off (`git commit -s`) per the + [Developer Certificate of Origin](https://github.com/ContextCake/context-cake/blob/main/DCO). diff --git a/.github/scripts/check-dco-test.sh b/.github/scripts/check-dco-test.sh new file mode 100755 index 0000000..6d17a9c --- /dev/null +++ b/.github/scripts/check-dco-test.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Proves check-dco.sh accepts signed work and rejects unsigned work, including +# the bot, merge-commit, and moved-base cases the gate has to get right. +set -euo pipefail + +check="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-dco.sh" +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +expect_ok() { + local label=$1 base_ref=$2 out + if ! out="$(bash "$check" "$base_ref" HEAD 2>&1)"; then + echo "DCO check test: ${label} — a valid branch was rejected" >&2 + printf '%s\n' "$out" >&2 + exit 1 + fi + printf '%s\n' "$out" +} + +expect_reject() { + local label=$1 base_ref=$2 out + if out="$(bash "$check" "$base_ref" HEAD 2>&1)"; then + echo "DCO check test: ${label} — an unsigned branch was accepted" >&2 + printf '%s\n' "$out" >&2 + exit 1 + fi + printf '%s\n' "$out" +} + +assert_contains() { + local label=$1 needle=$2 haystack=$3 + if ! printf '%s\n' "$haystack" | grep -qF -e "$needle"; then + echo "DCO check test: ${label} — output missing '${needle}'" >&2 + printf '%s\n' "$haystack" >&2 + exit 1 + fi +} + +mkdir -p "$tmpdir/repo" +cd "$tmpdir/repo" +git init --quiet -b main +git config user.name "Ada Contributor" +git config user.email "ada@example.com" +git config commit.gpgsign false + +echo base > file.txt +git add file.txt +git commit --quiet -s -m "base commit" +base="$(git rev-parse HEAD)" + +branch() { git checkout --quiet -B "$1" "$base"; } + +# A signed commit passes. +branch signed +echo signed >> file.txt +git commit --quiet -a -s -m "signed change" +out="$(expect_ok "signed commit" "$base")" +assert_contains "signed commit" "DCO OK" "$out" + +# An unsigned commit is rejected, with the expected trailer in the message. +branch unsigned +echo unsigned >> file.txt +git commit --quiet -a -m "unsigned change" +out="$(expect_reject "unsigned commit" "$base")" +assert_contains "unsigned commit" \ + "Signed-off-by: Ada Contributor " "$out" +assert_contains "unsigned commit" "git rebase --signoff" "$out" + +# A sign-off that does not match the commit author is rejected. +branch mismatch +echo mismatch >> file.txt +git commit --quiet -a -s --author="Bo Other " -m "mismatched sign-off" +out="$(expect_reject "mismatched sign-off" "$base")" +assert_contains "mismatched sign-off" \ + "Signed-off-by: Bo Other " "$out" + +# Trailer matching is case-insensitive. +branch lowercase +echo lowercase >> file.txt +git commit --quiet -a -m "lowercase trailer + +signed-off-by: Ada Contributor " +expect_ok "lowercase trailer" "$base" > /dev/null + +# A bot cannot run `git commit -s`, so bot-authored commits are skipped in the +# unenforced local-heuristic mode (no DCO_BOT_SHAS set). +branch bot +echo bot >> file.txt +git commit --quiet -a \ + --author="dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>" \ + -m "bump a dependency" +out="$(expect_ok "bot author" "$base")" +assert_contains "bot author" "(bot author)" "$out" + +# CI mode (DCO_BOT_SHAS set): a local author name is attacker-controlled +# before push — a human faking "[bot]" in their own commit must NOT skip the +# check just because the name heuristic would have matched it. +branch spoofed_bot +echo spoofed >> file.txt +git commit --quiet -a \ + --author="attacker[bot] " \ + -m "unsigned commit pretending to be a bot" +if out=$(DCO_BOT_SHAS="" bash "$check" "$base" HEAD 2>&1); then + echo "DCO check test: spoofed bot author — an unsigned branch was accepted" >&2 + printf '%s\n' "$out" >&2 + exit 1 +fi +assert_contains "spoofed bot author" \ + "Signed-off-by: attacker[bot] " "$out" + +# CI mode: a commit whose SHA the caller verified via the GitHub API skips, +# regardless of what its local author name says. +branch verified_bot +echo verified >> file.txt +git commit --quiet -a --author="Some Human " \ + -m "commit the API says is bot-authored" +verified_sha="$(git rev-parse HEAD)" +if ! out=$(DCO_BOT_SHAS="$verified_sha" bash "$check" "$base" HEAD 2>&1); then + echo "DCO check test: API-verified bot sha — a verified bot commit was rejected" >&2 + printf '%s\n' "$out" >&2 + exit 1 +fi +assert_contains "API-verified bot sha" "(bot author)" "$out" + +# Merge commits are generated, not authored, so they are skipped. +branch feature +echo feature >> file.txt +git commit --quiet -a -s -m "feature work" +branch merged +echo parallel > other.txt +git add other.txt +git commit --quiet -s -m "parallel work" +git merge --quiet --no-ff --no-edit feature +expect_ok "unsigned merge commit" "$base" > /dev/null + +# When the base branch has moved ahead, its own commits are not re-checked. +git checkout --quiet -B main "$base" +echo main-only > main-only.txt +git add main-only.txt +git commit --quiet -m "unsigned commit on the base branch" +branch behind-base +echo behind >> file.txt +git commit --quiet -a -s -m "signed work off an older base" +expect_ok "base branch ahead" main > /dev/null + +echo "DCO check-script test passed" diff --git a/.github/scripts/check-dco.sh b/.github/scripts/check-dco.sh new file mode 100755 index 0000000..22f0c1e --- /dev/null +++ b/.github/scripts/check-dco.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Verify that every commit in a range carries a Developer Certificate of Origin +# sign-off trailer matching its author. Plain bash + git, no third-party action: +# the contributor gate should not add a supply-chain surface the engine refuses +# to add for itself. +# +# Usage: check-dco.sh [head-ref] +# +# Merge commits are skipped — they are generated, not authored. +# +# Commits authored by a bot are skipped because a bot cannot execute +# `git commit -s`, matching the default behavior of GitHub's DCO app and +# keeping Dependabot PRs mergeable. Which commits count as "bot" depends on +# whether DCO_BOT_SHAS is set: +# - Set (CI): a space-separated allowlist of full SHAs the caller already +# verified via the GitHub API (commit author.type == "Bot"). Only those +# exact commits skip. A contributor's local git author name is theirs to +# set to anything before they ever push — `git config user.name +# 'x[bot]'` costs nothing — so trusting a name pattern here would let +# anyone opt their own commits out of certification. +# - Unset (local/manual runs): falls back to an "ends with [bot]" name +# heuristic, for developer convenience only. Nothing is enforced by a +# local run, so the spoofability doesn't matter there. +set -euo pipefail + +base=${1:?usage: check-dco.sh [head-ref]} +head=${2:-HEAD} + +# A PR-merge or shallow checkout may not contain the base commit yet. +if ! git cat-file -e "${base}^{commit}" 2>/dev/null; then + git fetch --no-tags --quiet origin "$base" 2>/dev/null || true +fi + +if ! git cat-file -e "${base}^{commit}" 2>/dev/null; then + echo "Could not resolve base commit ${base}." >&2 + exit 1 +fi + +# Compare against the fork point so commits already on the base branch are not +# re-checked when the base has moved ahead. +range_start="$(git merge-base "$base" "$head" 2>/dev/null || echo "$base")" + +is_bot_commit() { + local sha=$1 author_name=$2 + + if [ -n "${DCO_BOT_SHAS+set}" ]; then + case " ${DCO_BOT_SHAS} " in + *" ${sha} "*) return 0 ;; + *) return 1 ;; + esac + fi + + case "$author_name" in + *'[bot]') return 0 ;; + *) return 1 ;; + esac +} + +failed=0 +checked=0 +skipped=0 + +while IFS= read -r sha; do + [ -n "$sha" ] || continue + + author_name="$(git show -s --format='%an' "$sha")" + author_email="$(git show -s --format='%ae' "$sha")" + subject="$(git show -s --format='%s' "$sha")" + short="$(git rev-parse --short "$sha")" + + if is_bot_commit "$sha" "$author_name"; then + echo "skip ${short} ${subject} (bot author)" + skipped=$((skipped + 1)) + continue + fi + + checked=$((checked + 1)) + expected="Signed-off-by: ${author_name} <${author_email}>" + + if git show -s --format='%B' "$sha" | grep -qiF -e "$expected"; then + echo "ok ${short} ${subject}" + else + echo "FAIL ${short} ${subject}" + echo " missing trailer: ${expected}" + failed=$((failed + 1)) + fi +done < <(git rev-list --no-merges "${range_start}..${head}") + +if [ "$failed" -gt 0 ]; then + echo >&2 + echo "${failed} commit(s) are missing a sign-off matching their author." >&2 + echo >&2 + echo "Every commit must certify the Developer Certificate of Origin (see ./DCO)" >&2 + echo "with a trailer that matches the commit author exactly:" >&2 + echo >&2 + echo " Signed-off-by: Your Name " >&2 + echo >&2 + echo "To fix the most recent commit:" >&2 + echo " git commit --amend --signoff --no-edit" >&2 + echo >&2 + echo "To fix every commit on this branch:" >&2 + echo " git rebase --signoff ${base}" >&2 + echo >&2 + echo "Then update the pull request:" >&2 + echo " git push --force-with-lease" >&2 + exit 1 +fi + +echo "DCO OK — ${checked} commit(s) signed off, ${skipped} skipped." diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 0000000..e56bbcb --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,55 @@ +name: DCO + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + sign-off: + name: sign-off + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + # Full history: the check compares against the fork point with the base + # branch, so both parents of the pull request merge ref must be + # reachable. + fetch-depth: 0 + + - name: Test the check script + run: bash .github/scripts/check-dco-test.sh + + - name: Identify GitHub-verified bot commits + id: bots + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # A commit's local git author name is whatever the contributor set + # before pushing — never trust it for a security decision. The API's + # per-commit `author` object instead reflects GitHub's own account + # lookup, so `author.type == "Bot"` cannot be spoofed the same way. + shas="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits" --paginate \ + --jq '.[] | select(.author != null and .author.type == "Bot") | .sha' \ + | tr '\n' ' ')" + echo "shas=${shas}" >> "$GITHUB_OUTPUT" + + - name: Verify sign-off on every commit + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + DCO_BOT_SHAS: ${{ steps.bots.outputs.shas }} + run: bash .github/scripts/check-dco.sh "$BASE_SHA" "$HEAD_SHA" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abbf5db..e60017a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,46 @@ npm --prefix apps/site run build manifest. - Keep generated files out of Git unless a spec says otherwise. +## Sign Your Work + +ContextCake uses the [Developer Certificate of Origin](DCO) (DCO). Signing off +is how you certify that you wrote the contribution, or otherwise have the right +to submit it under this project's MIT license. There is no CLA to sign and no +copyright assignment. + +Add the trailer by committing with `-s`: + +```bash +git commit -s -m "fix(core): date sections from git history" +``` + +That appends a line matching your Git identity: + +```text +Signed-off-by: Your Name +``` + +Every commit in a pull request needs one, and it must match that commit's +author. Use your real name and an address you can be reached at; the sign-off +becomes a permanent part of the public history. + +To sign off work you already committed: + +```bash +git commit --amend --signoff --no-edit # the most recent commit +git rebase --signoff main # every commit on the branch +git push --force-with-lease +``` + +CI enforces this on every pull request. To check a branch before pushing: + +```bash +bash .github/scripts/check-dco.sh main +``` + +Merge commits are skipped, as are commits authored by bots — Dependabot cannot +run `git commit -s`. + ## Validation Run the smallest relevant checks while developing, then run the broader gates @@ -68,4 +108,6 @@ before opening a PR. `npm test` starts a local playground server, so it needs a local environment where binding to `127.0.0.1` is allowed. For AI contributors: state which commands you ran, include failures honestly, -and avoid broad rewrites unrelated to the issue. +and avoid broad rewrites unrelated to the issue. Agent-assisted commits are +signed off by the person submitting them — the DCO is a human certification, +so use the submitter's identity, not the agent's. diff --git a/DCO b/DCO new file mode 100644 index 0000000..fed7119 --- /dev/null +++ b/DCO @@ -0,0 +1,38 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +1 Letterman Drive +Suite D4700 +San Francisco, CA, 94129 + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/README.md b/README.md index dde4ea4..6d373fc 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ npm --prefix apps/desktop run smoke:bootfail ## Contributing -Contributions are welcome. Before opening a pull request, please read [CONTRIBUTING.md](CONTRIBUTING.md), keep `packages/core/` dependency-free, and run the checks relevant to the surface you changed. For security issues, use [private vulnerability reporting](https://github.com/ContextCake/context-cake/security/advisories/new) rather than a public issue. +Contributions are welcome. Before opening a pull request, please read [CONTRIBUTING.md](CONTRIBUTING.md), keep `packages/core/` dependency-free, and run the checks relevant to the surface you changed. Commits must be signed off with `git commit -s` to certify the [Developer Certificate of Origin](DCO) — there is no CLA. For security issues, use [private vulnerability reporting](https://github.com/ContextCake/context-cake/security/advisories/new) rather than a public issue. ## License