engineering-handbook/git-workflow.md
Adam Moussa affa1a64f8
Some checks failed
ci / ci / ci (push) Has been cancelled
docs(cd): separate terraform infra from github app deploys (#46)
Make HCP Terraform plus GitHub Actions content CD the default for new
workloads, and keep SAM/CDK documented as the remaining path.
2026-09-15 22:05:33 +00:00

169 lines
9.3 KiB
Markdown

# Git Workflow
## Branching
- Never commit directly to `main`
- Delete branches after merge
- Use the appropriate branch prefix for the type of work:
| Prefix | Use when |
|---|---|
| `feature/<description>` | Adding new functionality or enhancing existing features |
| `fix/<description>` | Fixing a non-urgent defect found during development or testing |
| `hotfix/<description>` | Fixing a production issue that needs immediate attention |
| `chore/<description>` | Routine maintenance, cleanup, dependency bumps, or tooling |
| `docs/<description>` | Documentation-only changes |
| `refactor/<description>` | Restructuring code without changing behavior |
| `release/<description>` | Preparing a release (version bump, tag, changelog) |
The prefixes mirror the commit types in [commit-messages.md](commit-messages.md#types), so the branch and its commits speak the same vocabulary. Use kebab-case for the description (e.g. `feature/add-receipt-parser`).
**fix vs hotfix:** Use `fix/` for defects caught before they affect production (failing tests, broken dev flows, issues found in review). Use `hotfix/` only when production is impacted and the fix needs to bypass normal review cadence.
**Jira key:** The Jira key does not go in the branch name. It goes in the PR title as a suffix (e.g. `feat(parser): add receipt parser (DEV-123)`). See [pull-requests.md](pull-requests.md#title) and [Linking to Jira](#linking-to-jira) below.
## Commits
- Commit each logical change individually with a descriptive message
- See [commit-messages.md](commit-messages.md) for formatting rules
- Keep the working tree clean: commit or stash before switching context
## Linking to Jira
Work is tracked in Jira (`seahaven.atlassian.net`). Jira is the source of truth for *work*; GitHub is the source of truth for *code*. We do not duplicate issues between the two — we link them.
The org-level **GitHub for Jira** app is already installed across all repos. It detects the Jira issue key (e.g. `DEV-123`) wherever it appears and surfaces the branch, commits, PR, and deployment status in that issue's development panel automatically. To make that happen:
- **Required:** include the key at the end of the PR title in parentheses — `feat(parser): add receipt parser (DEV-123)`. See [pull-requests.md](pull-requests.md#title) for the full format.
- **Recommended:** include the key in the `Refs:` trailer of each commit (see [commit-messages.md](commit-messages.md#referencing-issues)).
Branch names do not carry Jira keys. The required linkage point is the PR title.
### Smart Commit commands
The integration also accepts inline commands in commit messages to act on the issue without opening Jira:
| Command | Effect |
|---|---|
| `PROJ-123 #comment <text>` | Add a comment to the issue |
| `PROJ-123 #time 2h <text>` | Log work |
| `PROJ-123 #done` (or another transition name) | Transition the issue |
Transition names are case-insensitive and match the issue's workflow (`#in-progress`, `#done`). Use these sparingly; the link itself is the main goal, not driving the whole workflow from commits.
## Deploying
Nobody deploys from a workstation. The sanctioned paths depend on the lane. See [cicd.md](cicd.md).
**HCP Terraform (default).** Merge to `main` deploys **dev**: HCP auto-applies if `terraform/**` changed, and GitHub Actions deploys the app unless the merge was terraform-only. **Prod** is a human GitHub Release (`gh release create vX.Y.Z --target main --generate-notes`). That tag applies prod infra and queues the prod app deploys behind Environment reviewers. Rollback is `workflow_dispatch` of the deploy workflow at a prior tag, not a Terraform revert of application content.
**SAM / CDK (remaining).** Merge to `main` (or `workflow_dispatch` of that same pipeline) is still the only path.
The standard flow for an HCP app repo:
1. Create a feature branch (`feature/add-receipt-parser`)
2. Develop and commit locally
3. Open a PR via `gh pr create`
4. Get the review, and confirm CI is green (`gh pr checks`). Speculative HCP plans cover both workspaces.
5. Merge the PR on GitHub
6. Confirm the dev apply (if terraform changed) and the dev app deploy. Infra and app may share a PR; if the app job raced the apply, re-run it.
7. Cut prod with `gh release create`. Approve the prod Environment gate after the HCP prod apply lands. Verify live state, not action success.
8. If prod is wrong, dispatch the deploy workflow at the previous tag and approve the gate.
A local deploy puts code into an environment that no reviewed commit describes, and its result depends on whoever ran it having the right credentials and a clean working tree. The pipeline deploys a known commit with the repo's own OIDC role every time.
### Legacy exception: deploy-then-merge
Merging the feature branch into `main` locally, deploying by hand (`sam deploy`, `cdk deploy`), and only then merging the PR on GitHub is a legacy pattern. It is not a default and it is not available on request. Both of these must hold:
- **The change is data-loss-prone and stateful.** A resource replacement that would recreate a table or bucket, a one-way schema or data migration, or a stack operation that has to be watched and aborted mid-flight, where an automated deploy or rollback could destroy data.
- **Adam has signed off on that specific deploy in advance.**
Write the justification into the PR description under `## Notes`: what the stateful risk is, and where the sign-off happened. An exception that is not written down is not an exception, so use the pipeline.
## Push Discipline
- Push regularly; don't accumulate unpushed commits
- Never force push `main`
- Never amend or rewrite commits already pushed to `main`
- If you need to add context to a past change, use PR descriptions or issue comments
## Versioning
Use [Semantic Versioning](https://semver.org/) (SemVer) as the standard: `MAJOR.MINOR.PATCH`.
| Increment | When |
|---|---|
| `MAJOR` | Breaking changes to the public API or contract (removed endpoints, changed request/response shapes, incompatible config changes) |
| `MINOR` | New functionality that is backwards-compatible (new endpoints, new optional fields, new features) |
| `PATCH` | Backwards-compatible bug fixes, dependency updates, documentation corrections |
### When to version
HCP app repos always cut SemVer tags. The tag is what applies prod infra and what the prod app deploy checks out.
| Always version | Don't version |
|---|---|
| HCP Terraform app repos (the prod trigger) | One-off scripts and migration tools |
| Published packages (npm, PyPI) | Remaining SAM/CDK stacks that still deploy only on merge to `main` |
| Public APIs with external consumers | |
| Shared libraries used across repos | |
| CLIs distributed to users | |
### How to tag
For HCP app repos, cut a GitHub Release from `main` after the PR is merged. Do not use a workflow. Do not `git tag` as the normal path (a tag without a Release does not fire `release: published`):
```bash
gh release create v1.2.0 --target main --generate-notes
```
For packages and libraries that are not HCP app repos, annotated tags remain fine:
```bash
git tag -a v1.2.0 -m "v1.2.0"
git push origin v1.2.0
```
Start at `v0.1.0` for new projects. Move to `v1.0.0` when the interface is stable.
## Git Hooks
The global security `pre-push` hook at `~/.config/git/hooks/pre-push` is the only pre-push hook in active use. It runs the workstation security scanner before every push and is the deterministic gate for catching secrets and high-severity findings locally. Linting (ruff, tsc/eslint) runs in CI, not in pre-push.
The [`hooks/`](hooks/) directory ships a `pre-push` shim for use by repos that set their own `core.hooksPath`. That shim re-execs the global hook rather than replacing it. It is not an npm-ci runner and is not a general-purpose pre-push template.
### `core.hooksPath` shadows, it does not merge
`core.hooksPath` names a single directory, not a search path. Setting it in a repo (for example to a tracked `.githooks/`) makes git run hooks *only* from there and ignore the machine-global hooks directory completely. Where that global directory holds a mandatory hook, such as the workstation security `pre-push`, a repo-local `core.hooksPath` silently disables it: no error, no output, no gate.
A repo that sets its own `core.hooksPath` must ship a `pre-push` that re-execs the global hook instead of replacing it:
```bash
#!/usr/bin/env bash
GLOBAL="${SH_GLOBAL_HOOKS:-$HOME/.config/git/hooks}/pre-push"
if [ ! -x "$GLOBAL" ]; then
echo "pre-push: global security hook is missing or not executable: $GLOBAL" >&2
exit 1
fi
exec "$GLOBAL" "$@" # passes args and stdin, preserves the exit code
```
Two consequences:
- An installer that sets `core.hooksPath` must verify the shim delegates **before** it touches the config, and refuse to install if the delegation is missing or broken.
- A tracked hooks directory only exists on branches that contain it, so the gate is off on every branch that predates it. Land the hooks directory on all long-lived branches before installing.
Check what is actually in effect with `git config core.hooksPath` and `git config --local core.hooksPath`, then confirm the repo's `pre-push` still execs the global one.
## Cleaning Up
After a PR is merged:
```bash
git checkout main
git pull
git branch -d feature/add-receipt-parser
```
Don't let stale local branches or untracked project directories accumulate.