Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
2 changes: 1 addition & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#!/bin/sh
exec python3 "$(git rev-parse --show-toplevel)/bin/check.py"
exec python3 "$(dirname "$0")/../bin/check.py" --root "$(git rev-parse --show-toplevel)"
2 changes: 1 addition & 1 deletion .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#!/bin/sh
exec python3 "$(git rev-parse --show-toplevel)/bin/check.py" --pre-push
exec python3 "$(dirname "$0")/../bin/check.py" --root "$(git rev-parse --show-toplevel)" --pre-push
10 changes: 9 additions & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ on:
push:
pull_request:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: ourPLCC/change-requests
path: change-requests
- uses: actions/setup-python@v5
with:
python-version: "3.12"
Expand All @@ -22,6 +29,7 @@ jobs:
python3 -m unittest discover -s tests -t .
python3 -m unittest discover -s migrate/tests -t .
- name: Consistency check
run: python3 bin/check.py --pre-push
run: python3 bin/check.py --pre-push --root change-requests
- name: Backlog.md doctor
working-directory: change-requests
run: backlog doctor
100 changes: 99 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ Repo docs link to this guide rather than restating it, even to agree with it.
reports a problem or suggests an idea by opening an issue on the relevant
code repo.
- **Change requests (CRs)** are the work we commit to, tracked centrally in
this repo with [Backlog.md](https://github.com/MrLesk/Backlog.md). A CR is
[ourPLCC/change-requests](https://github.com/ourPLCC/change-requests) with
[Backlog.md](https://github.com/MrLesk/Backlog.md). A CR is
work in exactly one repo with testable acceptance criteria, and has an ID
`CR-N`. Work spanning repos is one CR per repo, linked by dependencies.
- **Drafts** are ideas we have not committed to. Never cite one.
Expand Down Expand Up @@ -58,3 +59,100 @@ guide — filing, statuses, IDs, references, checks — is
in the repo's git log. A CR's type is the commit type of the change that
decides its version impact (`fix` → patch, `feat` → minor); its branch may
carry other commit types too.

## Working in a devcontainer

Each ourPLCC code repo has a devcontainer. In it, you and any agent you run
act on GitHub as you, through one token of yours, and GitHub's settings, not
the agent's instructions, set the limits. The design and its reasons are in
[the devcontainer permissions spec](dev-docs/specs/2026-10-13-devcontainer-permissions-design.md).

### Your token

1. On GitHub, create a fine-grained personal access token (Settings →
Developer settings → Personal access tokens → Fine-grained tokens):
- Resource owner: `ourPLCC`. Repository access: all repositories.
- Repository permissions: Contents read and write, and Pull requests read
and write. Nothing else.
- Expiration: 90 days at most.
2. Wait for an org owner to approve it; until then it cannot reach ourPLCC.
3. In any ourPLCC container, run `gh auth login --with-token` and paste the
token. Every ourPLCC container on the machine shares the login, and it
survives rebuilds.

Never use plain `gh auth login`: its browser flow yields a token for every
repo you can reach. When the token expires, make a new one, have it approved,
and load it again. To revoke it, delete it on GitHub; to forget it on one
machine, run `docker volume rm ourplcc-gh` on the host.

Keep no SSH key that GitHub accepts loaded in your host's SSH agent; on the
host, use HTTPS with Git Credential Manager instead. VS Code forwards that
agent into every container, no setting turns it off, and the devcontainer can
only hide it, not block it, so a key there would let the container push
anywhere you can.

### What the container cannot do

From the container you can push branches, open and update pull requests, and
push tracker changes with `/workspaces/dev/bin/push.bash`. You cannot push to
any repo's `main` except `change-requests`', to `gh-pages`, or tags; push a
change to `.github/workflows/` (except from a Codespace; see below); run or
re-run workflows; or change settings,
rulesets, environments, or secrets. Do those in GitHub's web UI, or on the
host with your own credentials.

Merging a pull request is yours alone: in a code repo, merging to `main` is
the release. Agents never merge.

### Approving a release

A release reaches PyPI only after you approve it. Open the release's workflow
run in the repo's Actions tab, choose **Review deployments**, select `pypi`,
and approve. The token cannot approve, so no agent can.

### Codespaces

A Codespace uses its own GitHub token; do not run `gh auth login` there. When
you create one, GitHub asks you to authorize access to `ourPLCC/dev` and
`ourPLCC/change-requests`: authorize it, or both stay read-only and
`bin/push.bash` fails. A full rebuild of a Codespace deletes its volumes and
any tracker commits you have not pushed, so push right away.

If you use an agent regularly, use it in a local container, not a Codespace.
A Codespace's token can push changes to `.github/workflows/`, which your own
token cannot, so an agent misled by text it reads could push a workflow that
runs with write access to the repo. An agent for a one-off task in a Codespace
is fine; watch what it pushes.

A Codespace's token expires about 12 hours after the Codespace starts. If a
push or `gh` command then fails to authenticate, restart the Codespace.

### Moving from the host clone

Before the devcontainers cloned `dev` into a volume, it lived on the host
beside the code repos, and tracker changes were pushed from there. Move once:

1. In the host's `../dev`, check that `git status`,
`git log --branches --not --remotes`, `git worktree list`, and
`git stash list` come back clean. `dev`'s `main` no longer takes direct
pushes, so re-enter any tracker change they show by hand, in the
container, after step 3.
2. Create your token and wait for its approval.
3. Rebuild one ourPLCC container; it clones `dev` and `change-requests` into
their volumes. Run `gh auth login --with-token`, then push your next
tracker change with `/workspaces/dev/bin/push.bash`.
4. On a Windows host, once each code repo's branch has the
`* text=auto eol=lf` line in its `.gitattributes`, commit or stash your
work there, since the next command discards uncommitted changes. Then
check the repo out again (`git rm -r --cached -q . && git reset --hard`),
or clone it fresh.
5. Delete the host's `../dev`. Do not check out a branch of any repo from
before the move, or its devcontainer recreates `../dev`.
6. On the host, push over HTTPS with Git Credential Manager. It comes with
Git for Windows; on macOS, `brew install --cask git-credential-manager`.
ourPLCC admits only approved apps, and GCM is the one approved for
pushing, so a token another app stored fails with a 403. Erase it with
`printf 'protocol=https\nhost=github.com\n\n' | git credential reject`,
then push again and sign in through the browser. If your host clones use
SSH remotes, switch them to HTTPS and remove your GitHub key from the SSH
agent (`ssh-add -d <key>`).
5 changes: 5 additions & 0 deletions ORG-AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ because the reader is an agent.
(`backlog task edit CR-N --append-notes "…"`), never as new CRs.
- When closing the CR, triage those notes with the human: new draft, new CR,
fold into an existing CR, or drop.
- Never merge a pull request.
- Never run `gh auth login`, `gh auth refresh`, or `gh auth token`; if `gh`
is not logged in, stop and tell the human.
- Push `change-requests` only through `dev`'s `bin/push.bash`, and only to
publish tracker changes the human approved.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# ourPLCC developer docs and change requests
# ourPLCC developer docs

How we build ourPLCC, for maintainers, their agents, and new developers:
the developer guide shared by every ourPLCC repository, org-wide specs, agent
rules, and the central change-request tracker for work on plcc-ng,
languages-ng, plcc-ng-demo, plcc-ng-devcontainer, and course-materials-ng.
rules, and the tooling for the change-request tracker. The tracker itself, for
work on plcc-ng, languages-ng, plcc-ng-demo, plcc-ng-devcontainer, and
course-materials-ng, is
[ourPLCC/change-requests](https://github.com/ourPLCC/change-requests).

**Reporting a problem or suggesting an idea?** Open a GitHub issue on the
relevant code repository instead. GitHub Issues are where humans talk to us;
this repository is where maintainers track the resulting work.
the tracker is where maintainers track the resulting work.

**Start with [CONTRIBUTING.md](CONTRIBUTING.md)**, the developer guide.

Expand Down
9 changes: 6 additions & 3 deletions bin/check.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Consistency checks for the ourPLCC change-request tracker.

Standard library only: this runs on maintainers' hosts (pre-push hook) as
well as in containers, without Node or Backlog.md.
Standard library only: this runs from the hooks and in CI without Node or
Backlog.md. --root names the tracker repo (ourPLCC/change-requests).
"""
import argparse
import re
Expand Down Expand Up @@ -71,6 +71,8 @@ def _as_list(value):
def check(root, pre_push=False):
root = Path(root)
backlog = root / "backlog"
if not (backlog / "config.yml").is_file():
return [f"{root}: no backlog/config.yml; --root must name the tracker repo"]
config = load_config(backlog / "config.yml")
projects = _as_list(config.get("projects"))
types = _as_list(config.get("types"))
Expand Down Expand Up @@ -132,7 +134,8 @@ def rel(path):

def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--root", default=str(Path(__file__).resolve().parent.parent))
parser.add_argument("--root", required=True,
help="root of the tracker repo, which holds backlog/")
parser.add_argument("--pre-push", action="store_true",
help="also fail on uncommitted changes under backlog/")
args = parser.parse_args(argv)
Expand Down
78 changes: 78 additions & 0 deletions bin/push.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Push the change-request tracker (ourPLCC/change-requests): fetch, rebase
# onto origin/main, pass bin/check.py --pre-push, push. Holds a lock on the
# ourplcc-dev volume's mount point, /workspaces/dev, the same lock the code
# repos' bin/install/shared-clones.bash takes.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEV_ROOT="$(dirname "${SCRIPT_DIR}")"
TRACKER="${OURPLCC_CHANGE_REQUESTS:-/workspaces/change-requests}"
LOCK_DIR="${OURPLCC_LOCK_DIR:-/workspaces/dev}"

die() {
echo "push.bash: $*" >&2
exit 1
}

git -C "${TRACKER}" rev-parse --verify --quiet HEAD >/dev/null 2>&1 \
|| die "${TRACKER} is not a git clone of ourPLCC/change-requests" \
"(missing, empty, or an unfinished clone);" \
"rerun bin/install/shared-clones.bash"

# Re-run under the lock. -o keeps the lock's descriptor in flock alone, so a
# child that outlives this script (git maintenance, detached by fetch) does
# not hold it; -w turns a stuck holder into an error.
# OURPLCC_LOCK_DIR and OURPLCC_LOCK_WAIT are for tests.
if [ -z "${OURPLCC_PUSH_LOCKED:-}" ]; then
status=0
OURPLCC_PUSH_LOCKED=1 flock -o -w "${OURPLCC_LOCK_WAIT:-120}" -E 75 \
"${LOCK_DIR}" bash "${BASH_SOURCE[0]}" "$@" || status=$?
[ "${status}" -ne 75 ] \
|| die "timed out waiting for the lock on ${LOCK_DIR};" \
"another push or clone holds it"
exit "${status}"
fi
unset OURPLCC_PUSH_LOCKED

cd "${TRACKER}"

rebase_in_progress() {
[ -d "$(git rev-parse --git-path rebase-merge)" ] \
|| [ -d "$(git rev-parse --git-path rebase-apply)" ]
}

! rebase_in_progress \
|| die "a rebase is in progress in ${TRACKER}; finish it" \
"(git rebase --continue) or run 'git rebase --abort', then run this again"
[ "$(git symbolic-ref --quiet --short HEAD || true)" = main ] \
|| die "${TRACKER} is not on main; check out main first"
[ -z "$(git status --porcelain)" ] \
|| die "${TRACKER} has uncommitted changes; commit or discard them first"

git fetch --quiet origin
if ! git rebase --quiet origin/main; then
rebase_in_progress \
|| die "rebase onto origin/main failed (see git's message above);" \
"nothing changed; run this again"
git rebase --abort \
|| die "rebase onto origin/main conflicted and could not be aborted;" \
"run 'git rebase --abort' in ${TRACKER}"
die "rebase onto origin/main conflicted; aborted, local commits kept." \
"Resolve with 'git pull --rebase' in ${TRACKER}, then run this again"
fi

python3 "${DEV_ROOT}/bin/check.py" --pre-push --root "${TRACKER}" \
|| die "bin/check.py failed; nothing pushed. For a duplicate id," \
"run 'backlog doctor --fix', commit, and run this again"

if [ "$(git rev-list --count origin/main..HEAD)" -eq 0 ]; then
echo "push.bash: nothing to push"
exit 0
fi

git push --quiet origin main \
|| die "push failed (see git's message above). If another push landed" \
"since the fetch, run this again; if it is an authentication error," \
"see 'Your token' in dev's CONTRIBUTING.md"
echo "push.bash: pushed"
Loading
Loading