diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..0963fac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,48 @@ +name: Bug report +description: Report reproducible behavior that is incorrect +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Thanks for helping improve Bill. Do not include tokens, private keys, + real private server details, or other secrets. + - type: textarea + id: problem + attributes: + label: Problem + description: What happened, and who or what was affected? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Provide the smallest safe sequence that demonstrates the problem. + placeholder: | + 1. Configure ... + 2. Run ... + 3. Observe ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened instead? + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: What observable outcomes would confirm the bug is fixed? + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional safe context + description: Add sanitized logs or screenshots if useful. Remove secrets and private IDs. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..71660d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: Feature request +description: Propose an outcome or improvement for Bill +title: "[Feature]: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Start with the problem rather than only a preferred implementation. + Do not include secrets or private server information. + - type: textarea + id: problem + attributes: + label: Problem or opportunity + description: Who needs this, and what are they unable to do today? + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Describe the user-visible result. + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: List concrete conditions that would make this request complete. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Note workarounds or simpler approaches that were considered. + - type: textarea + id: context + attributes: + label: Additional context + description: Add safe examples, sketches, or related Issue links. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..3faa747 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + + + +## Related work + + + +## Checks + +- [ ] Python compile, Ruff, and pytest pass +- [ ] Worker typecheck and Vitest pass +- [ ] No secrets, private IDs, or local environment files are included +- [ ] Documentation and manual steps are updated when needed + +## Risk and rollout + + diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..6d8be23 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,14 @@ +changelog: + exclude: + labels: + - skip-changelog + categories: + - title: Features + labels: + - enhancement + - title: Fixes + labels: + - bug + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9a14bc4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,111 @@ +name: Release + +on: + push: + tags: + - "v*" + +# Validation needs only repository reads. The publishing job receives its own +# narrowly scoped write permission after every check succeeds. +permissions: + contents: read + +jobs: + validate: + name: Validate tag and code + runs-on: ubuntu-latest + outputs: + tag_object: ${{ steps.release_metadata.outputs.tag_object }} + steps: + - name: Check out the tagged revision with release history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch current main ref + run: git fetch --no-tags origin main:refs/remotes/origin/main + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: worker/package-lock.json + + - name: Validate release tag and synchronized versions + id: release_metadata + run: | + python scripts/validate_release.py "$GITHUB_REF_NAME" + tag_object="$(git rev-parse "refs/tags/${GITHUB_REF_NAME}")" + echo "tag_object=${tag_object}" >> "$GITHUB_OUTPUT" + + - name: Install Python dependencies + run: python -m pip install -e '.[dev]' + + - name: Compile Python + run: python -m compileall -q bill + + - name: Lint Python + run: ruff check bill tests + + - name: Test Python + run: pytest -q + + - name: Install Worker dependencies + working-directory: worker + run: npm ci + + - name: Type-check and test Worker + working-directory: worker + run: npm run check + + publish: + name: Publish GitHub Release + needs: validate + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Create release once + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_TAG_OBJECT: ${{ needs.validate.outputs.tag_object }} + TAG: ${{ github.ref_name }} + shell: bash + run: | + set -euo pipefail + + # Bind publishing to the exact annotated tag object that passed validation. + live_tag_object="$(gh api \ + "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" \ + --jq '.object.sha')" + if [[ "${live_tag_object}" != "${EXPECTED_TAG_OBJECT}" ]]; then + echo "Tag changed after validation; refusing to publish." >&2 + exit 1 + fi + + # A rerun must preserve any release text that a maintainer may have edited. + error_file="$(mktemp)" + trap 'rm -f "${error_file}"' EXIT + if release_url="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + --jq '.html_url' 2>"${error_file}")"; then + echo "Release already exists: ${release_url}" + exit 0 + fi + + if ! grep -q 'HTTP 404' "${error_file}"; then + cat "${error_file}" >&2 + exit 1 + fi + + gh release create "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --verify-tag \ + --generate-notes \ + --title "Bill ${TAG}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..74f2450 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,25 @@ +# Contributing to Bill + +Bill uses small reviewed changes rather than direct pushes to `main`. + +1. Open or choose a clear Issue when the work needs tracking or agreement. +2. Start one focused branch or coding session from current `main`. +3. Make the smallest coherent change; never include secrets or production IDs. +4. Inspect and commit the intended patch, then push the feature branch. +5. Open a focused PR into `main`, link its Issue, and describe what changed and + how it was checked. Use a draft while work is incomplete. +6. Run and fix the Python and Worker checks documented in the + [README](README.md). +7. Address review conversations in the same branch and resolve threads only + after replying. +8. Merge with an enabled repository strategy after checks and review pass. + Delete the branch only when its work is safely merged and no stack depends + on it. + +Use stacked PRs only for genuinely dependent, separately reviewable layers. +Coordinate before rebasing or force-updating any shared or session-owned branch. + +Read [version control](docs/version-control.md), [GitHub collaboration](docs/github-collaboration.md), +and [releases](docs/releases.md) before changing shared history or publishing a +version. Creating a GitHub Release never deploys Bill; production rollout +remains a separate manual process. diff --git a/README.md b/README.md index 94c0396..ce8a50f 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,15 @@ npm ci npm run check ``` -See [deployment](docs/deployment.md), [architecture](docs/architecture.md), and -[send tracking](docs/send-tracking.md) for the runnable setup and behavior. +## Guides + +- [Version control with Git and GitHub](docs/version-control.md) +- [Collaborating with Issues, PRs, reviews, and stacks](docs/github-collaboration.md) +- [Releasing Bill](docs/releases.md) +- [Contributing](CONTRIBUTING.md) +- [Deployment](docs/deployment.md) +- [Architecture](docs/architecture.md) +- [Send tracking](docs/send-tracking.md) ## Current scope diff --git a/docs/github-collaboration.md b/docs/github-collaboration.md new file mode 100644 index 0000000..40e3416 --- /dev/null +++ b/docs/github-collaboration.md @@ -0,0 +1,276 @@ +# Collaborating on Bill with GitHub + +Git records the code and its history. GitHub adds shared planning and review: +Issues describe work, pull requests propose changes, checks test them, and +reviews improve them before they reach `main`. + +## Issues: describe a problem before solving it + +Open an Issue when work should be discussed, prioritized, assigned, or tracked +separately from the code change. Good examples are a reproducible bug, a feature +whose outcome needs agreement, or a follow-up discovered during review. + +A useful Issue contains: + +- a concise, searchable title describing the outcome or problem; +- the current problem and who it affects; +- expected behavior; +- enough reproduction or context to understand it; +- clear acceptance criteria that say when the work is done. + +Do not put secrets, private server details, tokens, or personal data in an +Issue. Do not open one for a quick question better answered in chat or a +Discussion, a security report that needs private disclosure, duplicate work, or +a tiny fix already fully explained by its PR. + +### Organizing and linking Issues + +- **Labels** classify work, such as `bug`, `enhancement`, or documentation. Use + a small number that convey information. +- A **milestone** groups Issues and PRs toward a time or release goal. It is not + a promise that every item will ship. +- An **assignee** is the person currently responsible for moving the Issue + forward, not everyone interested in it. +- Links between Issues and PRs preserve the reason for a change. Put + `Closes #123` in the PR description when merging that PR should automatically + close Issue 123. Use `Related to #123` when the PR is relevant but does not + complete the Issue. + +Triage means checking new Issues for clarity, reproducing bugs, identifying +duplicates, applying useful labels or milestones, setting priority, and asking +for missing information. Close an Issue when it is completed, intentionally +declined, no longer relevant, or a duplicate; leave a brief reason. + +GitHub Projects can provide a board or roadmap across Issues and PRs. +Discussions are better for open-ended questions and ideas. Bill does not need +either for every small task. + +## Pull requests: propose one reviewable change + +A pull request (PR) compares a **head** branch containing proposed commits with +a **base** branch that should receive them. A normal Bill PR usually has a +feature branch as its head and `main` as its base. + +Avoid direct pushes to `main`. A branch and PR provide a visible diff, automated +checks, review discussion, and a safe place to revise work before accepted +history changes. + +### Draft and ready PRs + +Open a **draft PR** when the direction is useful to share but the work is not +ready to merge. Checks still provide early feedback and reviewers can comment, +but the draft status says that approval is premature. Mark it **ready for +review** after the scope, tests, and description are complete. + +Keep a PR focused on one outcome. Unrelated changes make review harder, hide +risk, and make reverts less precise. A good description answers: + +1. What changed? +2. Why is it needed? +3. How was it checked? +4. Are there risks, limitations, rollout steps, screenshots, or follow-ups? +5. Which Issue does it close or relate to? + +The head branch can receive more commits after the PR opens. GitHub updates the +same PR automatically. + +### Checks, review, and feedback + +Checks are automated evidence, not a substitute for review. For Bill, Python +and Worker checks should pass before merge. Read failures rather than rerunning +them blindly. + +A reviewer can approve, comment, or request changes. Treat a review thread as a +conversation about the code: + +1. Understand the concern and ask for clarification if needed. +2. Make the change in the same head branch, or explain respectfully why another + approach is safer. +3. Push the new commit. +4. Reply with what changed and resolve the thread only when the concern is + addressed. + +Do not hide unresolved concerns by resolving threads without replying. New +commits may make an earlier approval stale, so check whether another review is +required. + +### Merging and branch cleanup + +Bill can use the practical merge methods described in +[version control](version-control.md): merge commits preserve branch commits, +squash creates one commit per PR, and rebase creates linear commit history. Use +the repository's enabled method and write a useful final commit message. + +After a normal PR is safely merged, delete its remote branch if it is no longer +needed, then update local `main`. Never delete an unmerged branch until its work +is preserved elsewhere. Branch cleanup is different for an active stack because +upper branches still depend on lower ones. + +## Native stacked pull requests + +A stack divides one dependent change into small reviewable layers. Think +bottom-to-top: + +- the bottom branch starts from the trunk, normally `main`; +- each higher branch starts from the branch immediately below it; +- the bottom PR targets `main`; +- each higher PR targets the branch immediately below. + +Each layer should have one branch, one worktree or coding session, and one PR. +Commit and push a lower layer before creating the branch above it so the +dependency is explicit and recoverable. + +For a hypothetical Bill documentation improvement: + +```text +main + | + +-- docs/glossary PR 1: base main + | + +-- docs/git-guide PR 2: base docs/glossary + | + +-- docs/exercise PR 3: base docs/git-guide +``` + +PR 1 introduces shared terms. PR 2 uses those terms in a guide. PR 3 adds an +exercise that depends on both. Reviewers see only each layer's incremental diff. + +### A dependent chain versus a registered GitHub Stack + +Branches and PR bases can form a dependent chain without extra metadata. +GitHub's native stacked PR feature explicitly registers eligible PRs as one +**Stack**. Registration adds a stack map, stack-aware review/check requirements, +cascading rebase support, and stack merge behavior. + +On GitHub's website, choose **Create stack** when opening an upper PR against +the branch below, or accept GitHub's recommendation to turn an eligible chain +into a Stack. `gh stack` is an official GitHub CLI extension, not a built-in +command. Check that GitHub CLI is installed, then install the extension once: + +```bash +gh extension install github/gh-stack +``` + +Extension installation changes the local machine but does not open or modify +Issues or PRs. With the extension available, the native flow is: + +```bash +gh stack init docs/glossary +# edit, git add, and git commit the bottom layer +gh stack push +gh stack add docs/git-guide +# edit, git add, and git commit the upper layer +gh stack submit +``` + +`gh stack submit` pushes the branches, creates the correctly based PRs, and +links them with native Stack metadata. The feature may be in public preview, so +check the current GitHub UI and CLI documentation before relying on it for +critical work. + +### When a stack helps + +Use a stack when layers genuinely depend on each other but can be understood, +checked, and potentially reverted separately. Examples include a schema change +followed by an API followed by UI, or shared documentation followed by several +focused guides. + +Use one normal PR when the change is small, the layers would be artificial, or +reviewing an upper layer without repeatedly revisiting the lower layer would be +harder. A stack creates coordination and rebase cost; it is not a way to avoid +making each PR coherent. + +### Review, checks, and merge behavior + +GitHub evaluates each registered stacked PR against the stack's trunk rules, +even though an upper PR directly targets another feature branch. Workflows for +PRs targeting `main` run for every layer. Each layer has its own review and +checks, and upper layers include the lower code when tested. + +Stacks merge from the bottom upward. GitHub can merge a contiguous group as one +stack operation; selecting a higher layer also includes every unmerged layer +below it. After lower layers merge, GitHub rebases the next layer onto the trunk +so it becomes the new bottom. Review the stack map carefully before merging. + +If `main` advances or a lower branch changes, the stack may stop being linear. +Use GitHub's **Rebase stack** action for a server-side cascading rebase, or: + +```bash +gh stack rebase +gh stack push +``` + +The rebase starts at the bottom and reapplies every upper layer. It retriggers +checks. If a conflict occurs, resolve the correct layer, stage the result, and +use `gh stack rebase --continue`; use `gh stack rebase --abort` to return to the +pre-rebase state. + +A rebase rewrites commit identities and updating rebased remote branches +requires force-with-lease behavior. Never casually rebase or force-push a branch +owned by another active worktree, coding session, or contributor. Coordinate +with its owner first; otherwise you can invalidate their local history, lose +commits, and disrupt every layer above it. + +## Safe hands-on exercises + +Use documentation-only files and disposable branches. Read each command before +running it. Commands marked **REMOTE MUTATION** change shared GitHub state. + +### 1. Issue to draft PR + +1. In GitHub, open a practice Issue using the repository's feature form. Give it + an acceptance criterion such as "a practice glossary defines one harmless + term." Creating the Issue is a **REMOTE MUTATION**. +2. Create a local branch: + + ```bash + git switch main + git pull --ff-only + git switch -c practice/issue-pr + ``` + +3. Add a harmless documentation file, inspect it, stage it, and commit it. +4. Push it (**REMOTE MUTATION**): + + ```bash + git push -u origin practice/issue-pr + ``` + +5. Open a draft PR (**REMOTE MUTATION**) with base `main`, include + `Closes #`, and explain that it is a disposable exercise: + + ```bash + gh pr create --draft --base main --head practice/issue-pr + ``` + +6. Ask for a small review, respond to the review thread, commit and push the + improvement (**REMOTE MUTATION**), and resolve the addressed thread. +7. Close the draft PR without merging and delete the practice branch + (**REMOTE MUTATION**) unless a maintainer wants to preserve the exercise. + Closing an unmerged PR means its `Closes` keyword will not close the Issue; + close the practice Issue separately with an explanation. + +### 2. Sketch a two-layer documentation stack + +The safest exercise is to draw a proposed stack and stop before pushing: + +```text +main <- practice/stack-terms <- practice/stack-exercise +``` + +To create it locally, commit one disposable documentation file on +`practice/stack-terms`, then create `practice/stack-exercise` from that branch +and commit a second file. Inspect: + +```bash +git log --oneline --graph main..practice/stack-exercise +git diff main...practice/stack-terms +git diff practice/stack-terms...practice/stack-exercise +``` + +If the repository owner approves a remote exercise, use `gh stack init`, +`gh stack add`, and `gh stack submit` as above. `gh stack submit` is a **REMOTE +MUTATION** that pushes branches and opens PRs. Alternatively push each branch, +open the bottom PR against `main`, open the upper PR against the bottom branch, +and select **Create stack** in GitHub; each push and PR creation is a **REMOTE +MUTATION**. Do not merge practice PRs into `main`. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..cd11584 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,167 @@ +# Releasing Bill + +Bill follows [Semantic Versioning](https://semver.org/) using strict +`MAJOR.MINOR.PATCH` versions. Because Bill is still below `1.0.0`, minor releases +may contain intentional breaking changes and patch releases should remain +backward-compatible fixes. + +Examples from `0.1.0`: + +- `0.1.1` is a patch: a compatible bug or documentation fix. +- `0.2.0` is a minor: new functionality or an intentional compatibility change + while the product is pre-1.0. +- `1.0.0` is a major: Bill's first declared stable public contract. After 1.0, + incompatible changes increment the major version. + +GitHub's generated release notes are the authoritative changelog. The release +workflow does not deploy anything: Worker deployment and Discord bot rollout +remain separate, manual operations. + +## Release procedure + +Replace `0.1.1` below with the intended version. Use only a final +`MAJOR.MINOR.PATCH` version; prerelease and build suffixes are not accepted by +the initial workflow. + +### 1. Prepare and merge a version PR + +Create a focused branch from current `main`: + +```bash +git switch main +git pull --ff-only +git switch -c release/0.1.1 +``` + +Update `[project].version` in `pyproject.toml` and `version` in +`worker/package.json` to exactly `0.1.1`. Keep `worker/package-lock.json` +synchronized by using npm's version command: + +```bash +npm --prefix worker version 0.1.1 --no-git-tag-version +``` + +That command updates the Worker package manifest and lock file without making a +Git tag. Update `pyproject.toml` separately, then check the planned tag against +the manifests: + +```bash +python scripts/validate_release.py v0.1.1 --skip-git-checks +git diff -- pyproject.toml worker/package.json worker/package-lock.json +``` + +Commit the version changes, push the branch, open a PR, and wait for all checks +and review. Merge the PR into `main`. Do not tag the feature branch or the +unmerged PR commit. + +### 2. Create and inspect the annotated tag on `main` + +Return to `main`, update it without creating a local merge, and fetch tags: + +```bash +git switch main +git pull --ff-only +git fetch origin --tags +``` + +Confirm both versions and create an **annotated** tag: + +```bash +python scripts/validate_release.py v0.1.1 --skip-git-checks +git tag -a v0.1.1 -m "Bill v0.1.1" +git show --no-patch --decorate v0.1.1 +python scripts/validate_release.py v0.1.1 --main-ref origin/main +``` + +The final validator command proves that the tag is strict SemVer, annotated, +matches both manifests, and points to a commit contained in `origin/main`. + +### 3. Push only the tag + +```bash +git push origin v0.1.1 +``` + +Do not use `git push --tags`; it could publish unrelated local tags. Pushing the +single tag starts `.github/workflows/release.yml`. + +Watch the **Release** workflow in GitHub Actions. It installs dependencies and +runs the same Python compile, Ruff, pytest, Worker typecheck, and Vitest checks +as CI. Only after they all pass does it create **Bill v0.1.1** with GitHub +generated notes. Rerunning the workflow reports an existing release and leaves +its content unchanged. + +Inspect the GitHub Release page and its generated notes. The release records +source history only. When production should receive that version, make a +separate decision and follow [deployment](deployment.md) for the Worker and +Discord bot. Creating the release never runs `wrangler deploy`, connects to the +production host, or restarts the bot. + +## Rollback and correction + +Tags and GitHub Releases are historical records. Do not move or reuse a +published version tag, and do not treat deleting a release as a production +rollback. + +If source code needs correction, revert the bad commit in a reviewed PR, choose +a new patch version, and run the release procedure again. If production needs +rollback, separately decide which known-good commit or release to deploy and +perform the appropriate manual Worker/bot rollout. A source revert, a new +release, and a production rollback are related decisions but not the same +operation. + +If an incorrect tag has not produced a release and nobody else relies on it, +ask a maintainer before deleting or replacing it. Never force-update a published +release tag. + +## Troubleshooting + +### The tag format is rejected + +Use exactly `vMAJOR.MINOR.PATCH`, such as `v0.1.1`. `0.1.1`, `v01.1.0`, +`v0.1`, `v0.1.1-rc.1`, and `v0.1.1+build` are intentionally rejected. + +### The tag is lightweight + +Delete only the unpushed local tag, then recreate it with an annotation: + +```bash +git tag -d v0.1.1 +git tag -a v0.1.1 -m "Bill v0.1.1" +``` + +Do not replace a tag that has already been pushed without maintainer help. + +### The versions do not match + +The version after `v` must exactly match both `pyproject.toml` and +`worker/package.json`. Correct the manifests and lock file in a PR, merge it, +update local `main`, and tag the merged version commit. Do not edit manifests +directly on `main`. + +### The tagged commit is not on `main` + +The PR may be unmerged, local `main` may be stale, or the tag may have been +created while another branch was checked out. Inspect: + +```bash +git status +git branch --show-current +git fetch origin main +git show --no-patch --decorate v0.1.1 +git branch --remote --contains v0.1.1 +``` + +If the tag is still local, delete it and recreate it on updated `main`. If it +was pushed, stop and ask a maintainer rather than rewriting shared history. + +### Python or Worker checks fail + +No release is published. Fix the failure through a new PR, merge it, increment +to a new version if the tag was already shared, and create a new tag. A failed +workflow must never be bypassed by creating a release manually. + +### The release already exists + +This is expected on a workflow rerun. The publish job reports the existing URL +and does not overwrite generated or maintainer-edited notes. diff --git a/docs/version-control.md b/docs/version-control.md new file mode 100644 index 0000000..9116079 --- /dev/null +++ b/docs/version-control.md @@ -0,0 +1,184 @@ +# Version control with Git and GitHub + +This guide explains the pieces of Git that Bill uses and a safe day-to-day +workflow. Git records the repository's history; GitHub hosts a shared copy and +adds pull requests, automated checks, reviews, and releases. + +## The core ideas + +- A **repository** is the project and its Git history. The hidden `.git` + directory stores that history; do not edit it by hand. +- The **working tree** is the files currently visible in your checkout. Editing + a file changes the working tree but does not change history. +- The **staging area** is the exact set of changes selected for the next commit. + `git add` copies a change into this area. +- A **commit** is a named snapshot with an author, message, and parent commit. + Commits are permanent building blocks of history, not cloud backups by + themselves. +- A **branch** is a movable name pointing to a line of commits. Work on a short + feature branch rather than directly on `main`. +- A **remote** is another copy of the repository. In this project, `origin` + normally means the shared GitHub repository. +- A **tag** is a durable name for one commit. Bill uses annotated release tags + such as `v0.2.1`; unlike a branch, a release tag should never move. + +Three places matter while making a commit: + +```text +working tree --git add--> staging area --git commit--> branch history +``` + +`git status` shows how files are distributed across those places. + +## Bill's normal contribution path + +Start from an up-to-date `main`: + +```bash +git switch main +git pull --ff-only +git switch -c explain-one-purpose +``` + +Then use this loop: + +1. Make one focused change. +2. Inspect it with `git status` and `git diff`. +3. Stage the intended files with `git add path/to/file`. +4. Inspect the staged patch with `git diff --staged`. +5. Commit it with a short explanation: `git commit -m "Explain one purpose"`. +6. Push the branch: `git push -u origin explain-one-purpose`. +7. Open a pull request (PR) into `main`. +8. Wait for Python and Worker checks, respond to review, and push fixes to the + same branch. +9. Merge only after checks and review are complete. + +The PR makes the proposed difference visible before it enters `main`. Checks +show whether it builds and tests successfully; review checks whether the change +is understandable, safe, and appropriate. + +## Merge, squash, and rebase + +These are different ways to combine branch history: + +- A **merge commit** preserves the feature branch's individual commits and adds + a commit joining it to `main`. This is useful when the branch's internal + history tells a meaningful story. +- A **squash merge** turns the PR into one new commit on `main`. It is practical + for small PRs with fix-up commits because `main` stays easy to read. +- A **rebase** copies commits onto a newer base so history becomes linear. + Rebasing rewrites commit identities. It is useful for cleaning up your own + unpublished branch, but avoid rebasing a branch that other people use. + +The repository's GitHub settings determine which merge buttons are available. +Whichever method is used, the reviewed result must land on `main` before it can +be released. + +## `main`, tags, and GitHub Releases + +`main` is the shared record of accepted work. A release tag freezes the identity +of one commit on `main`. A GitHub Release is a web page attached to that tag, +with generated notes describing merged work. + +For Bill: + +```text +merged commit on main -> annotated vX.Y.Z tag -> validated GitHub Release +``` + +The release workflow checks the tag, both package versions, Python, and the +Worker. It creates release notes only after all checks pass. It does **not** +deploy the Worker, restart the Discord bot, or otherwise change production. + +## Safe inspection commands + +These commands do not rewrite history: + +```bash +git status +git diff +git diff --staged +git log --oneline --decorate --graph -20 +git branch --all +git remote -v +git show --stat +git tag --list +git fetch --prune +``` + +`git fetch` downloads remote history without merging it into your current +branch. Prefer `git pull --ff-only` on `main`: it stops rather than inventing a +merge commit when local and remote history differ. + +## What `.gitignore` does + +`.gitignore` tells Git which untracked paths should normally stay untracked. +Bill ignores virtual environments, dependency folders, build output, local +editor files, and secret-bearing environment/key files. + +It is not a security boundary. If a secret was already committed, adding its +filename to `.gitignore` does not erase history. Revoke or rotate the secret and +ask a maintainer for help. Before every commit, use `git diff --staged` to check +that credentials, tokens, private keys, real server IDs, and local environment +files are absent. + +## Straightforward merge conflicts + +A conflict means Git needs a human to choose the combined result. + +1. Run `git status` to see conflicted files. +2. Open each file and find the `<<<<<<<`, `=======`, and `>>>>>>>` markers. +3. Read both sides, edit the file into the correct final form, and remove all + markers. +4. Run the relevant checks. +5. Stage the resolved file with `git add path/to/file`. +6. Complete the operation with the command Git reports in `git status` + (commonly `git commit`, `git merge --continue`, or `git rebase --continue`). + +If the intended result is unclear, stop and ask the other contributor. Do not +choose a side merely to make the markers disappear. + +## Safe recovery + +First inspect; most mistakes do not require deleting work: + +```bash +git status +git diff +git diff --staged +``` + +- Staged the wrong file? `git restore --staged path/to/file` moves it out of the + staging area but keeps the working-tree edit. +- Need to discard one uncommitted file edit? Inspect it first, then use + `git restore path/to/file`. This discards that file's unstaged work. +- Need to undo a shared commit? `git revert ` creates a new commit that + reverses it, preserving an honest shared history. +- On the wrong branch with uncommitted work? Stop and ask before moving it if + you are uncertain. A small commit on a temporary branch is safer than + destructive cleanup. + +Avoid `git reset --hard`: it can permanently discard uncommitted work. Never +force-push a shared branch, delete a branch whose work is not safely merged, or +commit secrets. These shortcuts can destroy another person's work or expose +credentials. + +## Exercises on disposable branches + +Use branches that contain no valuable work: + +1. **Stage and unstage:** create `practice.txt`, inspect it, stage it, inspect + the staged diff, then run `git restore --staged practice.txt`. Delete the + untracked practice file afterward. +2. **Make two commits:** on `practice/two-commits`, add two harmless lines in + separate commits. Compare `git log --oneline main..HEAD` with + `git diff main...HEAD`. +3. **Resolve a toy conflict:** create two disposable branches that change the + same line in `practice.txt`, merge one into the other, and resolve the + markers. Do not use production code for the exercise. +4. **Practice revert:** commit a harmless practice file, run + `git revert `, and inspect how both the original and reversal remain + in `git log`. + +Delete a practice branch only after switching away from it and confirming that +it contains nothing you need. diff --git a/scripts/validate_release.py b/scripts/validate_release.py new file mode 100644 index 0000000..f2d09de --- /dev/null +++ b/scripts/validate_release.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Validate that a Bill release tag is safe to publish.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +TAG_PATTERN = re.compile(r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") + + +class ValidationError(Exception): + """A release does not satisfy Bill's publishing rules.""" + + +def version_from_tag(tag: str) -> str: + """Return the package version represented by a strict vMAJOR.MINOR.PATCH tag.""" + match = TAG_PATTERN.fullmatch(tag) + if match is None: + raise ValidationError( + f"{tag!r} is not a strict SemVer tag; expected vMAJOR.MINOR.PATCH " + "with no leading zeroes or prerelease/build suffix" + ) + return ".".join(match.groups()) + + +def read_manifest_versions(repo_root: Path) -> tuple[str, str]: + try: + with (repo_root / "pyproject.toml").open("rb") as file: + python_version = tomllib.load(file)["project"]["version"] + with (repo_root / "worker/package.json").open(encoding="utf-8") as file: + worker_version = json.load(file)["version"] + except (KeyError, OSError, tomllib.TOMLDecodeError, json.JSONDecodeError) as error: + raise ValidationError(f"could not read project versions: {error}") from error + + if not isinstance(python_version, str) or not isinstance(worker_version, str): + raise ValidationError("both manifest versions must be strings") + return python_version, worker_version + + +def validate_manifest_versions(tag: str, repo_root: Path) -> str: + version = version_from_tag(tag) + python_version, worker_version = read_manifest_versions(repo_root) + if python_version != worker_version: + raise ValidationError( + "manifest versions differ: " + f"pyproject.toml={python_version!r}, worker/package.json={worker_version!r}" + ) + if version != python_version: + raise ValidationError( + f"tag {tag!r} represents {version!r}, but both manifests use {python_version!r}" + ) + return version + + +def run_git(repo_root: Path, *arguments: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *arguments], + cwd=repo_root, + check=check, + capture_output=True, + text=True, + ) + + +def validate_git_tag(tag: str, repo_root: Path, main_ref: str) -> None: + tag_ref = f"refs/tags/{tag}" + try: + object_type = run_git(repo_root, "cat-file", "-t", tag_ref).stdout.strip() + except subprocess.CalledProcessError as error: + raise ValidationError(f"tag {tag!r} does not exist in this checkout") from error + if object_type != "tag": + raise ValidationError(f"tag {tag!r} is lightweight; create an annotated tag with git tag -a") + + # Resolve both revisions before the ancestry check so a missing main ref has a clear error. + try: + run_git(repo_root, "rev-parse", "--verify", f"{main_ref}^{{commit}}") + tag_commit = run_git(repo_root, "rev-parse", "--verify", f"{tag_ref}^{{commit}}").stdout.strip() + except subprocess.CalledProcessError as error: + raise ValidationError(f"could not resolve release tag or main ref {main_ref!r}") from error + + ancestry = run_git( + repo_root, + "merge-base", + "--is-ancestor", + tag_commit, + main_ref, + check=False, + ) + if ancestry.returncode == 1: + raise ValidationError(f"tag {tag!r} points to a commit that is not on {main_ref}") + if ancestry.returncode != 0: + raise ValidationError( + f"git could not compare tag {tag!r} with {main_ref!r}: {ancestry.stderr.strip()}" + ) + + +def validate_release( + tag: str, + repo_root: Path, + *, + main_ref: str = "origin/main", + check_git: bool = True, +) -> str: + version = validate_manifest_versions(tag, repo_root) + if check_git: + validate_git_tag(tag, repo_root, main_ref) + return version + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate Bill's strict release tag, synchronized versions, and Git history." + ) + parser.add_argument("tag", help="release tag in strict vMAJOR.MINOR.PATCH form") + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="repository root (defaults to the parent of scripts/)", + ) + parser.add_argument( + "--main-ref", + default="origin/main", + help="main branch ref used for the ancestry check (default: origin/main)", + ) + parser.add_argument( + "--skip-git-checks", + action="store_true", + help="only compare tag syntax and manifest versions; never use this for publishing", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + version = validate_release( + args.tag, + args.repo_root.resolve(), + main_ref=args.main_ref, + check_git=not args.skip_git_checks, + ) + except ValidationError as error: + print(f"release validation failed: {error}", file=sys.stderr) + return 1 + + scope = "tag and manifests" if args.skip_git_checks else "tag, manifests, and main history" + print(f"release validation passed for Bill {version} ({scope})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_validate_release.py b/tests/test_validate_release.py new file mode 100644 index 0000000..e8efdce --- /dev/null +++ b/tests/test_validate_release.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from scripts.validate_release import ValidationError, validate_release, version_from_tag + + +def run_git(repo: Path, *arguments: str) -> None: + subprocess.run(["git", *arguments], cwd=repo, check=True, capture_output=True, text=True) + + +def write_manifests(repo: Path, python_version: str, worker_version: str) -> None: + (repo / "worker").mkdir(exist_ok=True) + (repo / "pyproject.toml").write_text( + f'[project]\nname = "bill-discord-bot"\nversion = "{python_version}"\n', + encoding="utf-8", + ) + (repo / "worker/package.json").write_text( + json.dumps({"name": "bill-worker", "version": worker_version}), + encoding="utf-8", + ) + + +@pytest.fixture +def release_repo(tmp_path: Path) -> Path: + run_git(tmp_path, "init", "-b", "main") + run_git(tmp_path, "config", "user.name", "Release Test") + run_git(tmp_path, "config", "user.email", "release-test@example.invalid") + write_manifests(tmp_path, "0.1.0", "0.1.0") + run_git(tmp_path, "add", "pyproject.toml", "worker/package.json") + run_git(tmp_path, "commit", "-m", "Prepare release") + run_git(tmp_path, "tag", "-a", "v0.1.0", "-m", "Bill v0.1.0") + return tmp_path + + +def test_accepts_annotated_matching_tag_on_main(release_repo: Path) -> None: + assert validate_release("v0.1.0", release_repo, main_ref="main") == "0.1.0" + + +@pytest.mark.parametrize( + "tag", + [ + "0.1.0", + "v0.1", + "v01.1.0", + "v0.1.0-rc.1", + "v0.1.0+build", + "v0.1.0extra", + "v\u0661.2.3", + ], +) +def test_rejects_non_strict_tags(tag: str) -> None: + with pytest.raises(ValidationError, match="strict SemVer"): + version_from_tag(tag) + + +@pytest.mark.parametrize( + ("python_version", "worker_version", "message"), + [("0.1.1", "0.1.0", "manifest versions differ"), ("0.1.1", "0.1.1", "tag .* represents")], +) +def test_rejects_manifest_version_mismatches( + release_repo: Path, + python_version: str, + worker_version: str, + message: str, +) -> None: + write_manifests(release_repo, python_version, worker_version) + with pytest.raises(ValidationError, match=message): + validate_release("v0.1.0", release_repo, main_ref="main", check_git=False) + + +def test_rejects_lightweight_tag(release_repo: Path) -> None: + run_git(release_repo, "tag", "v0.1.1") + write_manifests(release_repo, "0.1.1", "0.1.1") + with pytest.raises(ValidationError, match="lightweight"): + validate_release("v0.1.1", release_repo, main_ref="main") + + +def test_rejects_tag_not_on_main(release_repo: Path) -> None: + run_git(release_repo, "switch", "-c", "not-main") + (release_repo / "branch-only.txt").write_text("branch-only\n", encoding="utf-8") + run_git(release_repo, "add", "branch-only.txt") + run_git(release_repo, "commit", "-m", "Branch-only commit") + run_git(release_repo, "tag", "-a", "v0.1.1", "-m", "Bill v0.1.1") + write_manifests(release_repo, "0.1.1", "0.1.1") + + with pytest.raises(ValidationError, match="not on main"): + validate_release("v0.1.1", release_repo, main_ref="main")