Skip to content
Merged
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
11 changes: 9 additions & 2 deletions .github/workflows/builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ concurrency:

jobs:
lint-code:
name: Code Linter 🧹
name: Lint Code 🧹
runs-on: ubuntu-slim
permissions:
contents: read
Expand All @@ -39,7 +39,7 @@ jobs:
- uses: golangci/golangci-lint-action@v9

lint-actions:
name: Workflow Linter 📋
name: Lint Action ⚙️
runs-on: ubuntu-slim
permissions:
contents: read
Expand Down Expand Up @@ -87,13 +87,20 @@ jobs:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Test
run: go test ./...
- name: Build tailor
run: go build -o tailor ./cmd/tailor
- name: Baste
run: ./tailor baste
env:
GH_TOKEN: ${{ secrets.TAILOR_TOKEN || secrets.GITHUB_TOKEN }}

security:
name: Security 🔒
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ tailor/
- `EvaluateTrigger(source string, repo any)` uses reflection to match yaml tags on `RepositorySettings`; `repo` is `any` (not `*config.RepositorySettings`) to avoid a circular import
- Five commands: `fit` (bootstrap), `alter` (apply), `baste` (preview), `measure` (inspect), `docket` (inspect)
- `fit`, `alter`, and `baste` require a valid GitHub auth token at startup; `measure` and `docket` do not
- GitHub Actions installation tokens (`secrets.GITHUB_TOKEN`) cannot call user-scoped endpoints (e.g. `GET /user`); features hitting such endpoints must check `GITHUB_ACTIONS=true` and fall back to Actions env vars (e.g. `GITHUB_REPOSITORY_OWNER` for the owner name) - see `internal/gh/user.go` for the pattern
- `alter` execution order: repository settings, then labels, then licence, then swatches
- SHA-256 comparison for `always` and `triggered` swatches; substituted swatches (`.github/FUNDING.yml`, `SECURITY.md`, `.github/ISSUE_TEMPLATE/config.yml`, `.tailor.yml`, `.github/workflows/tailor-automerge.yml`) compare the resolved content hash against the on-disk file
- `triggered` swatches deploy when their condition is met (overwrite like `always`), remove the file when the condition becomes false, and skip when the file is absent and condition is false
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,39 @@ The `wimpysworld/tailor` action installs the tailor binary and optionally runs o
| `measure` | Run `tailor measure` to check community health files and configuration alignment. | `false` |
| `docket` | Run `tailor docket` to display authentication state and repository context. | `false` |

### Token requirements

`GITHUB_TOKEN` is sufficient for most tailor operations in CI. Two fields are the exception: `default_workflow_permissions` and `can_approve_pull_request_reviews` call the `PUT /repos/{owner}/{repo}/actions/permissions/workflow` endpoint, which requires repository administration access. No `permissions:` block grants `GITHUB_TOKEN` this scope - it is a GitHub platform constraint.

When `GITHUB_TOKEN` is used, tailor skips those fields and reports:

```
would skip (insufficient scope: token missing required scope): default_workflow_permissions
would skip (insufficient scope: token missing required scope): can_approve_pull_request_reviews
```

To manage these fields from CI, provide a PAT with the necessary access.

#### Personal repositories

Create one of the following:

- **Classic PAT** at <https://github.com/settings/tokens> - enable the `repo` scope
- **Fine-grained PAT** at <https://github.com/settings/personal-access-tokens/new> - set "Repository permissions > Administration" to "Read and write"

#### Organisation repositories

Use the same PAT creation steps above. The PAT must belong to a user with admin access to the repository. If the organisation enforces SSO, authorise the PAT for the org after creation via the token's "Configure SSO" link.

#### Storing and using the PAT

Add the PAT as a repository secret via **Settings > Secrets and variables > Actions**, then pass it as `GH_TOKEN` in the workflow:

```yaml
env:
GH_TOKEN: ${{ secrets.TAILOR_TOKEN }}
```

### Supported platforms

Linux (amd64, arm64) and macOS (amd64, arm64).
Expand Down
12 changes: 7 additions & 5 deletions docs/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Several fields use separate API endpoints rather than the repository PATCH call.

**Topics**: The PUT endpoint replaces the entire topics list. The config declares the complete desired set; omitted topics are removed on apply. Topics are project-specific and not included in the default config template. Topic names must start with a lowercase letter or number, contain only lowercase alphanumerics and hyphens, and be 50 characters or fewer. The `topics` field uses `*[]string` semantics: nil (absent) means skip, empty list means clear all topics.

**Actions workflow permissions**: `default_workflow_permissions` accepts `read` or `write`. The PUT endpoint sends both `default_workflow_permissions` and `can_approve_pull_request_reviews` atomically. The tailor defaults (`read` and `false`) follow the principle of least privilege. GitHub defaults vary by context: personal repositories default to restricted `GITHUB_TOKEN` permissions with PR approval disabled, while organisation repositories inherit these settings from organisation-level Actions configuration.
**Actions workflow permissions**: `default_workflow_permissions` accepts `read` or `write`. The PUT endpoint sends both `default_workflow_permissions` and `can_approve_pull_request_reviews` atomically. The tailor defaults (`read` and `false`) follow the principle of least privilege. GitHub defaults vary by context: personal repositories default to restricted `GITHUB_TOKEN` permissions with PR approval disabled, while organisation repositories inherit these settings from organisation-level Actions configuration. Managing these fields from GitHub Actions CI requires a PAT - either a classic PAT with `repo` scope or a fine-grained PAT with the Administration repository permission. `GITHUB_TOKEN` lacks the required scope for the `GET/PUT /repos/{owner}/{repo}/actions/permissions/workflow` endpoint; when tailor runs with `GITHUB_TOKEN`, these two fields are skipped with a `would skip (insufficient scope)` result in `baste` and silently skipped by `alter`. This is graceful degradation, not an error.

Settings deliberately excluded due to risk or org-level scope: `visibility`, `default_branch`, `name`, `archived`, `is_template`, `allow_forking`, `security_and_analysis`. Additional API areas considered and deferred: Actions permissions policy (`enabled`, `allowed_actions`), autolinks, Pages configuration, deployment environments, custom properties (org-level), and Dependabot secrets. Branch protection (both classic rules and rulesets) is explicitly out of scope. It requires `Administration: write` - the same permission level needed to delete a repository - which `GITHUB_TOKEN` cannot hold at all; this is a deliberate GitHub security boundary preventing workflows from weakening the rules that govern their own repository. For Tailor's target audience of solo developers and small teams, branch protection is a one-time UI operation that does not drift over time, so the declarative consistency argument that justifies Tailor does not apply. Supporting it would roughly double the PAT privilege requirements for CI users for a setting they configure once, and `gh` CLI handles the setup in a single command, leaving no gap for Tailor to fill.

Expand Down Expand Up @@ -239,7 +239,7 @@ Behaviour:
- For `triggered` swatches: looks up the trigger condition for the swatch source in the trigger condition table. If the condition is met (e.g. `allow_auto_merge: true` in the `repository` section), behaves like `always` - deploys and overwrites when content differs. If the condition is not met and the file exists on disk, removes it. If the condition is not met and the file does not exist, skips silently. Triggered swatches are never overwritten by `--recut` when the trigger condition is false.
- For `never` swatches: skips entirely. No file is written, compared, or removed. This mode suppresses any swatch, including triggered swatches whose condition would otherwise be met.
- For licences: if `.tailor.yml` contains a `license` key with a value other than `none`, and no `LICENSE` file exists on disk, fetches the licence text via the GitHub REST API (`GET /licenses/{id}`) and writes it to `LICENSE`. The text is written verbatim as returned by GitHub - no token substitution is performed. Always treated as `first-fit`; the on-disk `LICENSE` file is never overwritten. If the licence fetch fails (e.g. unrecognised licence identifier), `alter` exits with the API error.
- For `.github/FUNDING.yml`: substitutes `{{GITHUB_USERNAME}}` before writing. `{{GITHUB_USERNAME}}` is resolved at `alter` time from `GET /user`. The Sponsorships checkbox under Settings > General > Features is not exposed via the GitHub API. After alter places `.github/FUNDING.yml`, enable sponsorships manually in repository settings.
- For `.github/FUNDING.yml`: substitutes `{{GITHUB_USERNAME}}` before writing. `{{GITHUB_USERNAME}}` is resolved at `alter` time: in GitHub Actions (`GITHUB_ACTIONS=true`), it is taken from `GITHUB_REPOSITORY_OWNER` without an API call; otherwise it is resolved via `GET /user`. The Sponsorships checkbox under Settings > General > Features is not exposed via the GitHub API. After alter places `.github/FUNDING.yml`, enable sponsorships manually in repository settings.
- For `SECURITY.md`: substitutes `{{ADVISORY_URL}}` before writing. `{{ADVISORY_URL}}` is constructed at `alter` time as `https://github.com/<owner>/<name>/security/advisories/new` from the repository context (owner/name). If no GitHub repository context exists (e.g. a brand-new project with no remote), `{{ADVISORY_URL}}` is left unsubstituted in the written file. The unsubstituted token is intentionally detectable by a future `measure` run; `alter` will resolve and substitute it on a subsequent run once the repository has a remote.
- For `.github/ISSUE_TEMPLATE/config.yml`: substitutes `{{SUPPORT_URL}}` before writing. `{{SUPPORT_URL}}` is constructed at `alter` time as `https://github.com/<owner>/<name>/blob/HEAD/SUPPORT.md` from the repository context (owner/name). If no GitHub repository context exists, `{{SUPPORT_URL}}` is left unsubstituted in the written file.
- For `.tailor.yml`: substitutes `{{HOMEPAGE_URL}}` before writing. `{{HOMEPAGE_URL}}` is constructed at `alter` time as `https://github.com/<owner>/<name>` from the repository context (owner/name). If no GitHub repository context exists, `{{HOMEPAGE_URL}}` is left unsubstituted in the written file.
Expand Down Expand Up @@ -291,7 +291,7 @@ would skip (insufficient role: <detail>): update label "documentation"
`would create` - label does not exist on GitHub and would be created.
`would update` - label exists on GitHub but colour or description differs from config.
`no change` - label exists on GitHub and matches config.
`would skip (insufficient scope: <detail>)` / `would skip (insufficient role: <detail>)` - label operation could not be applied due to token or role constraints.
`would skip (insufficient scope: <detail>)` / `would skip (insufficient role: <detail>)` - operation could not be applied due to token or role constraints. For labels, this occurs when the token lacks sufficient scope or the user lacks admin role. For repository settings, this occurs when the token lacks administration scope - notably `default_workflow_permissions` and `can_approve_pull_request_reviews` require a PAT (classic `repo` scope or fine-grained with Administration permission); `GITHUB_TOKEN` is always skipped for these two fields.

Label entries are sorted: `would create` first, then `would update`, then `no change`, then `would skip` variants. Within each category, sorted lexicographically by label name.

Expand Down Expand Up @@ -406,7 +406,7 @@ auth: not authenticated
```

Behaviour:
- `user` is resolved via `GET /user` if authenticated; displays `(none)` if not authenticated.
- `user` is resolved via `GET /user` if authenticated (or from `GITHUB_REPOSITORY_OWNER` in GitHub Actions); displays `(none)` if not authenticated.
- `repository` displays the `owner/repo` derived from the GitHub remote in the current directory; displays `(none)` if no GitHub remote exists.
- `auth` displays `authenticated` or `not authenticated` based on whether a valid token can be resolved for `github.com`.
- Does not read `.tailor.yml` and does not require it to be present.
Expand All @@ -427,14 +427,16 @@ Behaviour:

**Not authenticated**: if no valid authentication token can be resolved for `github.com` (neither `GH_TOKEN`/`GITHUB_TOKEN` environment variable, `gh` config file, nor `gh` keyring), `fit`, `alter`, and `baste` exit with: "tailor requires GitHub authentication. Set the GH_TOKEN or GITHUB_TOKEN environment variable, or run `gh auth login`."

**`{{GITHUB_USERNAME}}` resolution failed**: `{{GITHUB_USERNAME}}` is resolved via the GitHub REST API (`GET /user`). If this call fails (e.g. rate limits, network issues), `alter` exits with the API error. Unlike repo-context tokens, `{{GITHUB_USERNAME}}` depends on the authenticated user, not the repository, so it cannot be deferred.
**`{{GITHUB_USERNAME}}` resolution failed**: outside GitHub Actions, `{{GITHUB_USERNAME}}` is resolved via `GET /user`. If this call fails (e.g. rate limits, network issues), `alter` exits with the API error. In GitHub Actions (`GITHUB_ACTIONS=true`), `GITHUB_REPOSITORY_OWNER` is used instead and no API call is made, so this failure path does not apply. Unlike repo-context tokens, `{{GITHUB_USERNAME}}` depends on the authenticated user, not the repository, so it cannot be deferred.

**Repo-context tokens unresolved**: `{{ADVISORY_URL}}`, `{{SUPPORT_URL}}`, and `{{HOMEPAGE_URL}}` require a GitHub repository context. If the project has no GitHub remote (e.g. a brand-new project not yet pushed), these tokens are left unsubstituted silently. For `always` swatches (e.g. `SECURITY.md`), `alter` will resolve and substitute them on a subsequent run once the repository has a remote. For `first-fit` swatches (e.g. `.github/ISSUE_TEMPLATE/config.yml`), delete the file and re-run `alter`, or use `--recut`.

**Repository settings without repo context**: if `.tailor.yml` contains a `repository` section but the project has no GitHub remote (no repository context found), repository settings are skipped with a warning: "No GitHub repository context found. Repository settings will be applied once a remote is configured." Warning only; does not block swatch or licence processing.

**Repository settings API failure**: if any API call to apply repository settings fails (PATCH, PUT, or DELETE), `alter` exits with the API error. Because repository settings are applied first in the execution order, labels, licence, and swatch operations are not attempted. If licence fetch fails after repository settings and labels have been applied, those changes are not reverted.

**Repository settings with insufficient scope**: `default_workflow_permissions` and `can_approve_pull_request_reviews` require administration access that `GITHUB_TOKEN` (a GitHub Actions installation token) does not have. When tailor detects a 403 response from the `GET/PUT /repos/{owner}/{repo}/actions/permissions/workflow` endpoint due to insufficient token scope, it skips these two fields rather than exiting. `baste` reports them as `would skip (insufficient scope: token missing required scope)`. `alter` skips them silently. Other repository settings continue to be applied. To manage these fields from GitHub Actions CI, use a classic PAT with `repo` scope or a fine-grained PAT with the Administration repository permission as the `GH_TOKEN`/`GITHUB_TOKEN` environment variable.

**Unrecognised repository setting**: if `.tailor.yml` contains a field in the `repository` section that is not in the supported settings list, `alter` exits with an error identifying the unrecognised field and listing all valid repository setting field names.

**`fit` repository settings query failed**: if `fit` detects a GitHub remote but the subsequent API call to read repository settings fails (e.g. insufficient permissions, network error), `fit` exits with the API error. The user can re-run `fit` after resolving the issue, or create `.tailor.yml` manually.
Expand Down
1 change: 1 addition & 0 deletions internal/alter/alter_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ func WithPatchError(statusCode int) testOption {
// returns an alterTestContext ready for use with alter.Run.
func setupAlterTest(t *testing.T, configYAML string, opts ...testOption) *alterTestContext {
t.Helper()
t.Setenv("GITHUB_ACTIONS", "") // prevent env-var shortcut in FetchUsername

sc := &alterServerConfig{
username: "testuser",
Expand Down
11 changes: 11 additions & 0 deletions internal/alter/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,19 @@ func compareSettings(declared, live *model.RepositorySettings) []RepoSettingResu
// readWarningOperationFields maps read-path operation names (from
// ErrInsufficientScope/ErrInsufficientRole) to the config field names
// (YAML tags) they affect. Workflow permissions covers two fields.
// The installation token entry covers fields that return zero values
// in GitHub Actions.
var readWarningOperationFields = map[string][]string{
"fetch workflow permissions": {"default_workflow_permissions", "can_approve_pull_request_reviews"},
gh.InstallationTokenReadOp: {
"allow_auto_merge",
"allow_rebase_merge",
"allow_squash_merge",
"allow_update_branch",
"delete_branch_on_merge",
"squash_merge_commit_message",
"squash_merge_commit_title",
},
}

// readWarningsToResults converts read-path access-error warnings into
Expand Down
Loading
Loading