@erclx/canon 5.50.0 → 5.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4037 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/fan-in.md +20 -6
- package/claude/skills/canon-cli/SKILL.md +9 -7
- package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
- package/claude/skills/ci-workflow/SKILL.md +26 -1
- package/claude/skills/ci-workflow/references/workflows.md +28 -0
- package/claude/skills/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/SKILL.md +2 -1
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/references/pr.md +2 -2
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-pr/references/post.md +2 -2
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-check/references/domains.md +8 -6
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-update/REQUIREMENT.md +33 -0
- package/claude/skills/target-update/SKILL.md +136 -0
- package/docs/agents/index.md +1 -1
- package/docs/agents/migrate.md +3 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +3 -1
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +1 -1
- package/governance/ledger/tooling/ci-workflow.md +6 -0
- package/governance/ledger/ui/design-reference.md +8 -13
- package/governance/rules/tooling/ci-workflow.md +4 -0
- package/governance/rules/ui/design-reference.md +3 -6
- package/package.json +2 -1
- package/scripts/ci/check-browsers.sh +13 -0
- package/scripts/ci/delete-previews.sh +34 -0
- package/scripts/ci/publish-check.sh +29 -0
- package/scripts/ci/refresh-frames-pr.sh +63 -0
- package/scripts/ci/report-baseline.sh +23 -0
- package/scripts/ci/stack-issues.sh +24 -0
- package/scripts/ci/verify-npm-token.sh +14 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/cli/help.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/changelog.ts +146 -0
- package/src/commands/hooks.ts +7 -5
- package/src/commands/labels.ts +9 -7
- package/src/commands/migrate.ts +77 -0
- package/src/commands/worktrees.ts +273 -0
- package/src/gov/detect.ts +28 -10
- package/src/hooks/post-merge.ts +45 -6
- package/src/migrate/status.ts +132 -0
- package/src/version/changelog.ts +156 -0
- package/src/worktrees/members.ts +540 -0
- package/tooling/base/configs/.husky/post-merge +7 -6
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
- package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
- package/tooling/cloudflare/reference.md +7 -4
- package/tooling/web/reference.md +5 -0
- package/tooling/web/seeds/canon/design/index.md +23 -0
|
@@ -22,6 +22,12 @@ The field is continuous integration on GitHub Actions, and the authority is GitH
|
|
|
22
22
|
| Skip a fork's pull request | [Workflow syntax, permissions](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax): "if the workflow was triggered by a pull request event other than `pull_request_target` from a forked repository, and the Send write tokens to workflows from pull requests setting is not selected, the permissions are adjusted to change any write permissions to read only" | 2026-10-18 |
|
|
23
23
|
| Never commit back to the trunk | House choice: a workflow pushing onto the trunk lands a change no pull request carried and no reviewer read | 2026-10-18 |
|
|
24
24
|
|
|
25
|
+
## Steps
|
|
26
|
+
|
|
27
|
+
| Bullet | Source | Read |
|
|
28
|
+
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
29
|
+
| Long steps call a script, 5 max | House choice, from [issue 2531](https://github.com/erclx/canon/issues/2531): a 33-line publish check copied out of a workflow carried a program no laptop or shellcheck could run. [Workflow syntax, run](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) documents `run` as a command run in the shell, with no limit on its size. One guard stays inline, since a placeholder or secrets guard of three lines gains nothing from a script | 2026-10-21 |
|
|
30
|
+
|
|
25
31
|
## Token scopes
|
|
26
32
|
|
|
27
33
|
| Bullet | Source | Read |
|
|
@@ -9,13 +9,14 @@ The rule states canon's own design handoff, generalized from Frameboard's projec
|
|
|
9
9
|
|
|
10
10
|
## Where the design lives
|
|
11
11
|
|
|
12
|
-
| Bullet | Source
|
|
13
|
-
| ------------------------------ |
|
|
14
|
-
| Build against the tracked copy | House choice: a canvas changes under the build with no history, so a reviewer cannot tell which version a surface was built against
|
|
15
|
-
| Load the keeper skill | House choice: the rule governs an edit and carries no procedure, so a session without the skill copies frames in by hand or skips the index row
|
|
16
|
-
| Land a design
|
|
17
|
-
| No hand edits | House choice: a reference edited by hand drifts from its canvas source, and the next sync overwrites the edit or reports it as a change nobody made
|
|
18
|
-
| Index row before the sync | House choice: the sync writes only the surfaces the index names, so a missing row drops the new surface's reference
|
|
12
|
+
| Bullet | Source | Read |
|
|
13
|
+
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
14
|
+
| Build against the tracked copy | House choice: a canvas changes under the build with no history, so a reviewer cannot tell which version a surface was built against | 2026-10-18 |
|
|
15
|
+
| Load the keeper skill | House choice: the rule governs an edit and carries no procedure, so a session without the skill copies frames in by hand or skips the index row | 2026-10-21 |
|
|
16
|
+
| Land a design with its build | House choice: a design merged as its own pull request waits on a review that reads a picture with no build beside it, and the pick drifts before anything uses it | 2026-10-21 |
|
|
17
|
+
| No hand edits | House choice: a reference edited by hand drifts from its canvas source, and the next sync overwrites the edit or reports it as a change nobody made | 2026-10-18 |
|
|
18
|
+
| Index row before the sync | House choice: the sync writes only the surfaces the index names, so a missing row drops the new surface's reference | 2026-10-21 |
|
|
19
|
+
| Reference lifetime | House choice: a reference kept past its surface misleads the next build, and one redrawn without a design change hides a departure the review should see | 2026-10-21 |
|
|
19
20
|
|
|
20
21
|
## Building against a reference
|
|
21
22
|
|
|
@@ -32,12 +33,6 @@ The rule states canon's own design handoff, generalized from Frameboard's projec
|
|
|
32
33
|
| Name each departure | House choice: an unnamed departure reads as an accident, and the next build against the same reference repeats or reverts it | 2026-10-18 |
|
|
33
34
|
| Unexplained departure is a finding | House choice: without the finding, an unexplained departure merges as the new design with no one having chosen it | 2026-10-18 |
|
|
34
35
|
|
|
35
|
-
## Naming
|
|
36
|
-
|
|
37
|
-
| Bullet | Source | Read |
|
|
38
|
-
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
39
|
-
| One folder per app part | House choice: a fixed folder and theme suffix let the sync and a reviewer find a surface's pair of references without reading an index | 2026-10-18 |
|
|
40
|
-
|
|
41
36
|
## Declined
|
|
42
37
|
|
|
43
38
|
The rewrite kept every bullet. Each was already under 200 characters and states a step a reviewer can check against the pull request.
|
|
@@ -17,6 +17,10 @@ paths:
|
|
|
17
17
|
- Skip a pull request from a fork with `github.event.pull_request.head.repo.full_name == github.repository`, since the default token cannot push to a fork's branch.
|
|
18
18
|
- Never commit back to the trunk. A commit with no pull request and no reviewer needs an operator's decision, not a workflow's.
|
|
19
19
|
|
|
20
|
+
## Steps
|
|
21
|
+
|
|
22
|
+
- Keep an inline `run:` step to five lines at most with one guard allowed and no other branching, loop, trap, or function, and call a script for anything longer, per the `canon:ci-workflow` skill.
|
|
23
|
+
|
|
20
24
|
## Token scopes
|
|
21
25
|
|
|
22
26
|
- In a workflow with more than one job, add a write scope to the job that needs it, never to the workflow-level block, so every other job keeps the read-only token.
|
|
@@ -18,9 +18,10 @@ paths:
|
|
|
18
18
|
|
|
19
19
|
- Build UI against `canon/design/`, never against the canvas directly.
|
|
20
20
|
- Load the `canon:design-record` skill before adding, redrawing, or removing a reference under `canon/design/`, and report it rather than proceeding silently when it does not resolve.
|
|
21
|
-
- Land a picked or changed
|
|
21
|
+
- Land a picked or changed design in `canon/design/` through the project's design sync, in the first commit of the branch that builds against it or with its design round, never as its own pull request.
|
|
22
22
|
- Never copy, edit, or delete a reference file under `canon/design/` by hand.
|
|
23
|
-
- Add a new surface's row to the index in `canon/design/` before running the sync
|
|
23
|
+
- Add a new surface's row to the index in `canon/design/` before running the sync.
|
|
24
|
+
- Keep a reference while its surface exists, redraw it only on an intentional design change, and remove it with its index row in the change that removes the surface.
|
|
24
25
|
|
|
25
26
|
## Building against a reference
|
|
26
27
|
|
|
@@ -32,7 +33,3 @@ paths:
|
|
|
32
33
|
- Put each changed surface's reference image beside its built capture, light and dark, in the pull request description.
|
|
33
34
|
- Name each place the build departs from its reference, with its reason, in the pull request description.
|
|
34
35
|
- Report an unexplained departure from a reference as a review finding.
|
|
35
|
-
|
|
36
|
-
## Naming
|
|
37
|
-
|
|
38
|
-
- Give each app part one plain kebab-case folder under `canon/design/`, and name each file `<name>-dark` or `<name>-light`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.52.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"tooling",
|
|
15
15
|
"claude",
|
|
16
16
|
"docs",
|
|
17
|
+
"CHANGELOG.md",
|
|
17
18
|
"tsconfig.json",
|
|
18
19
|
"assets/brand/mark.svg",
|
|
19
20
|
"!scripts/eval",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Fails when a Playwright browser named in the arguments is missing from the
|
|
3
|
+
# cache, called after the cache restore in pr-visual-checks.yml. A cache hit
|
|
4
|
+
# skips the install step, so a restore that came back empty would otherwise
|
|
5
|
+
# surface later as a launch error.
|
|
6
|
+
set -e
|
|
7
|
+
|
|
8
|
+
for browser in "$@"; do
|
|
9
|
+
if ! compgen -G "$HOME/.cache/ms-playwright/${browser}-*" >/dev/null; then
|
|
10
|
+
echo "Missing Playwright browser: ${browser}" >&2
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
done
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Deletes the Cloudflare Pages preview deployments minted for one branch, called
|
|
3
|
+
# by the cleanup-preview job in deploy-site.yml with the Pages project name and
|
|
4
|
+
# the branch. CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID come from the step
|
|
5
|
+
# env.
|
|
6
|
+
#
|
|
7
|
+
# Deployments are listed through the REST API rather than wrangler, whose
|
|
8
|
+
# listing carries no branch filter. `force=true` is what lets a deployment
|
|
9
|
+
# still holding its branch alias be deleted.
|
|
10
|
+
#
|
|
11
|
+
# tooling/cloudflare/configs/scripts/delete-previews.sh is the seeded copy,
|
|
12
|
+
# which adds a skip for an absent secret. Keep the two otherwise the same.
|
|
13
|
+
set -e
|
|
14
|
+
|
|
15
|
+
PROJECT_NAME=$1
|
|
16
|
+
BRANCH=$2
|
|
17
|
+
|
|
18
|
+
api="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$PROJECT_NAME/deployments"
|
|
19
|
+
auth="Authorization: Bearer $CLOUDFLARE_API_TOKEN"
|
|
20
|
+
page=1
|
|
21
|
+
ids=""
|
|
22
|
+
while :; do
|
|
23
|
+
listing=$(curl -fsS -H "$auth" "$api?env=preview&per_page=25&page=$page")
|
|
24
|
+
[ "$(echo "$listing" | jq '.result | length')" -eq 0 ] && break
|
|
25
|
+
ids="$ids $(echo "$listing" | jq -r --arg branch "$BRANCH" '.result[] | select(.deployment_trigger.metadata.branch == $branch) | .id')"
|
|
26
|
+
page=$((page + 1))
|
|
27
|
+
done
|
|
28
|
+
deleted=0
|
|
29
|
+
for id in $ids; do
|
|
30
|
+
curl -fsS -X DELETE -H "$auth" "$api/$id?force=true" >/dev/null
|
|
31
|
+
echo "Deleted $id"
|
|
32
|
+
deleted=$((deleted + 1))
|
|
33
|
+
done
|
|
34
|
+
echo "Deleted $deleted preview deployments for $BRANCH."
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Reports whether the newest GitHub release reached npm, called by the
|
|
3
|
+
# publish-check job in release-please.yml. The step passes GH_TOKEN and GH_REPO
|
|
4
|
+
# for gh. A release inside the publish job's 15-minute window passes, since its
|
|
5
|
+
# publish may still be running.
|
|
6
|
+
set -e
|
|
7
|
+
|
|
8
|
+
package=@erclx/canon
|
|
9
|
+
|
|
10
|
+
if ! release=$(gh release view --json tagName,publishedAt --jq '"\(.tagName) \(.publishedAt)"') || [ -z "$release" ]; then
|
|
11
|
+
echo "::error::Could not read the newest GitHub release, so whether it reached npm is unknown."
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
read -r tag published <<<"$release"
|
|
15
|
+
age=$(($(date +%s) - $(date -d "$published" +%s)))
|
|
16
|
+
if [ "$age" -lt 900 ]; then
|
|
17
|
+
echo "$tag is ${age}s old, inside the 15-minute window its publish job runs in."
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
# A missing version is an E404, so read the whole list to tell it from an outage
|
|
21
|
+
if ! versions=$(npm view "$package" versions --json); then
|
|
22
|
+
echo "::error::Could not read $package from npm, so whether $tag reached it is unknown."
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
if ! jq -e --arg v "${tag#v}" '[.] | flatten | index($v) != null' <<<"$versions" >/dev/null; then
|
|
26
|
+
echo "::error::$tag has a GitHub release but npm does not serve it. Publish it with: gh workflow run release-please.yml -f tag=$tag"
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
echo "$tag is on npm."
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Commits the regenerated capture frames onto the refresh branch and opens or
|
|
3
|
+
# updates its pull request, called by refresh-capture-frames.yml after the
|
|
4
|
+
# capture steps. The step holds GH_TOKEN and HUSKY, since the token scope is
|
|
5
|
+
# read off the workflow.
|
|
6
|
+
#
|
|
7
|
+
# Hooks stay off, since pre-push runs a check whose format step needs shfmt,
|
|
8
|
+
# which the runner lacks. verify.yml checks the pull request.
|
|
9
|
+
#
|
|
10
|
+
# The frames commit where they rendered, then only the files that commit wrote
|
|
11
|
+
# or dropped carry onto main fetched now, so a merge landing mid-run neither
|
|
12
|
+
# leaves the branch on an older base nor loses a frame it moved. The pull
|
|
13
|
+
# request resolves by number, since every merged refresh leaves a closed one on
|
|
14
|
+
# the same head.
|
|
15
|
+
set -e
|
|
16
|
+
|
|
17
|
+
branch=chore/refresh-capture-frames
|
|
18
|
+
title="chore(web): refresh capture frames"
|
|
19
|
+
|
|
20
|
+
if [ -z "$(git status --porcelain assets/captures assets/frames assets/evidence)" ]; then
|
|
21
|
+
echo "Frames are current, nothing to refresh."
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
git config user.name "github-actions[bot]"
|
|
25
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # canon-allow-reference: the bot account id, not a commit
|
|
26
|
+
git add assets/captures assets/frames assets/evidence
|
|
27
|
+
git commit -m "$title"
|
|
28
|
+
rendered=$(git rev-parse HEAD)
|
|
29
|
+
written=$(git diff --name-only --no-renames --diff-filter=d HEAD~1 HEAD)
|
|
30
|
+
dropped=$(git diff --name-only --no-renames --diff-filter=D HEAD~1 HEAD)
|
|
31
|
+
git fetch --depth=1 origin +refs/heads/main:refs/remotes/origin/main
|
|
32
|
+
git checkout -B "$branch" origin/main
|
|
33
|
+
if [ -n "$written" ]; then
|
|
34
|
+
printf '%s\n' "$written" | xargs -d '\n' git checkout "$rendered" --
|
|
35
|
+
fi
|
|
36
|
+
if [ -n "$dropped" ]; then
|
|
37
|
+
printf '%s\n' "$dropped" | xargs -d '\n' git rm -q --ignore-unmatch --
|
|
38
|
+
fi
|
|
39
|
+
if git diff --cached --quiet -- assets/captures assets/frames assets/evidence; then
|
|
40
|
+
echo "Main already carries these frames, nothing to refresh."
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
git commit -m "$title"
|
|
44
|
+
git push --force origin "$branch"
|
|
45
|
+
number=$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')
|
|
46
|
+
if [ -z "$number" ]; then
|
|
47
|
+
gh pr create \
|
|
48
|
+
--base main \
|
|
49
|
+
--head "$branch" \
|
|
50
|
+
--title "$title" \
|
|
51
|
+
--body "## Summary
|
|
52
|
+
|
|
53
|
+
- Refreshes the capture frames from main so their counts and tokens stay current.
|
|
54
|
+
|
|
55
|
+
## Key changes
|
|
56
|
+
|
|
57
|
+
- Regenerates the count and token bearing frames, their images, and their stamps from main.
|
|
58
|
+
- Recaptures the home page baseline under assets/evidence/home.
|
|
59
|
+
- Merges itself by squash once its required check passes."
|
|
60
|
+
number=$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')
|
|
61
|
+
fi
|
|
62
|
+
# Every run, so a force-push from a later run leaves it queued either way
|
|
63
|
+
gh pr merge "$number" --auto --squash
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Writes the step summary saying whether the captured landing page frames match
|
|
3
|
+
# the committed baseline, called by the capture job in pr-visual-checks.yml. A
|
|
4
|
+
# moved capture is expected when a catalog the page counts has moved, so it
|
|
5
|
+
# reports and never fails.
|
|
6
|
+
set -e
|
|
7
|
+
|
|
8
|
+
if git diff --quiet -- assets/evidence/home; then
|
|
9
|
+
echo "The captured frames match the committed baseline." >>"$GITHUB_STEP_SUMMARY"
|
|
10
|
+
exit 0
|
|
11
|
+
fi
|
|
12
|
+
{
|
|
13
|
+
echo "### The landing page capture moved"
|
|
14
|
+
echo
|
|
15
|
+
echo "These frames differ from the committed baseline. That is expected"
|
|
16
|
+
echo "when a catalog this page counts has moved, and it is not a failure."
|
|
17
|
+
echo
|
|
18
|
+
echo '```'
|
|
19
|
+
git diff --stat -- assets/evidence/home
|
|
20
|
+
echo '```'
|
|
21
|
+
echo
|
|
22
|
+
echo "Download the \`landing-page-capture\` artifact to view them."
|
|
23
|
+
} >>"$GITHUB_STEP_SUMMARY"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Opens, updates, or closes one issue per stack from the outcomes stack-verify.yml
|
|
3
|
+
# recorded under results/<stack>, called by its report job. Takes the stacks as
|
|
4
|
+
# arguments and reads GH_TOKEN, GH_REPO, and RUN_URL from the step env.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
for stack in "$@"; do
|
|
8
|
+
title="stack-verify: $stack"
|
|
9
|
+
# A job that died before recording counts as a failure
|
|
10
|
+
outcome=failure
|
|
11
|
+
[ -f "results/$stack" ] && outcome=$(cat "results/$stack")
|
|
12
|
+
number=$(gh issue list --state open --search "\"$title\" in:title" --json number,title \
|
|
13
|
+
--jq "[.[] | select(.title == \"$title\")][0].number // empty")
|
|
14
|
+
if [ "$outcome" = success ]; then
|
|
15
|
+
if [ -n "$number" ]; then
|
|
16
|
+
gh issue close "$number" --comment "Passing again in $RUN_URL"
|
|
17
|
+
fi
|
|
18
|
+
elif [ -n "$number" ]; then
|
|
19
|
+
gh issue comment "$number" --body "Still failing in $RUN_URL"
|
|
20
|
+
else
|
|
21
|
+
gh issue create --title "$title" \
|
|
22
|
+
--body "\`canon tooling verify $stack\` failed in $RUN_URL. The issue closes itself when a later run passes."
|
|
23
|
+
fi
|
|
24
|
+
done
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Checks that the npm publish credential is set and authenticates, so a release
|
|
3
|
+
# is never cut it cannot publish. release-please.yml passes NPM_TOKEN from the
|
|
4
|
+
# step env, which keeps the secret named in the workflow.
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
if [ -z "$NPM_TOKEN" ]; then
|
|
8
|
+
echo "::error::NPM_TOKEN is not set. Add the secret before a release can be cut."
|
|
9
|
+
exit 1
|
|
10
|
+
fi
|
|
11
|
+
if ! curl -sf -H "Authorization: Bearer $NPM_TOKEN" https://registry.npmjs.org/-/whoami >/dev/null; then
|
|
12
|
+
echo "::error::NPM_TOKEN did not authenticate. It is likely expired or revoked."
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
@@ -66,6 +66,13 @@ export const SETUP_CASES: readonly SkillCase[] = [
|
|
|
66
66
|
'Report what this project has fallen behind the toolkit on, domain by domain, and fix nothing.',
|
|
67
67
|
expect: 'target-check',
|
|
68
68
|
},
|
|
69
|
+
// The applying half of the same moment. It previews what the check would
|
|
70
|
+
// report and then writes, which is the line between the two.
|
|
71
|
+
{
|
|
72
|
+
prompt:
|
|
73
|
+
'A new toolkit release is out, bring this project up to it and run whatever migrations it needs.',
|
|
74
|
+
expect: 'target-update',
|
|
75
|
+
},
|
|
69
76
|
{
|
|
70
77
|
prompt:
|
|
71
78
|
"This project needs its own coding rule that the toolkit doesn't ship.",
|
package/src/cli/help.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
|
|
|
20
20
|
['sync [path]', 'Sync all installed domains in a project'],
|
|
21
21
|
['upgrade', 'Reinstall the CLI with the manager that installed it'],
|
|
22
22
|
['migrate [cmd]', 'Move a project off a retired name or layout'],
|
|
23
|
+
['changelog', 'Read the releases between a stamp and this binary'],
|
|
23
24
|
['targets [cmd]', 'Projects this toolkit installed into'],
|
|
24
25
|
],
|
|
25
26
|
},
|
package/src/cli.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { register as census } from '@/commands/census'
|
|
|
40
40
|
import { register as upstream } from '@/commands/upstream'
|
|
41
41
|
import { register as targets } from '@/commands/targets'
|
|
42
42
|
import { register as upgrade } from '@/commands/upgrade'
|
|
43
|
+
import { register as changelog } from '@/commands/changelog'
|
|
43
44
|
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
44
45
|
import { renderHelp } from '@/cli/help'
|
|
45
46
|
import { renderAll, routeHelp } from '@/cli/help-all'
|
|
@@ -100,6 +101,7 @@ upstream(program)
|
|
|
100
101
|
audits(program)
|
|
101
102
|
gate(program)
|
|
102
103
|
upgrade(program)
|
|
104
|
+
changelog(program)
|
|
103
105
|
|
|
104
106
|
const route = routeHelp(program, process.argv.slice(2))
|
|
105
107
|
if (route.kind === 'top') showHelp()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import type { Command } from 'commander'
|
|
4
|
+
import { PROJECT_ROOT } from '@/roots/project'
|
|
5
|
+
import { readStamp } from '@/sync/stamp'
|
|
6
|
+
import {
|
|
7
|
+
intro,
|
|
8
|
+
logInfo,
|
|
9
|
+
logStep,
|
|
10
|
+
logWarn,
|
|
11
|
+
outro,
|
|
12
|
+
pipeOutput,
|
|
13
|
+
plural,
|
|
14
|
+
} from '@/cli/ui'
|
|
15
|
+
import { type ChangelogReport, resolveChangelog } from '@/version/changelog'
|
|
16
|
+
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
17
|
+
|
|
18
|
+
interface ChangelogOptions {
|
|
19
|
+
readonly since?: string
|
|
20
|
+
readonly root?: string
|
|
21
|
+
readonly json?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const REFUSALS = {
|
|
25
|
+
'no-stamp':
|
|
26
|
+
'the target carries no stamped version and --since named none, so there is no start to range from',
|
|
27
|
+
'no-changelog':
|
|
28
|
+
'the installed package carries no CHANGELOG.md with release entries to read',
|
|
29
|
+
'unknown-version':
|
|
30
|
+
'the changelog lists no such release, so the range would be a guess',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
const HELP = [
|
|
34
|
+
'',
|
|
35
|
+
'Prints the release entries after a version, up to the running one, from the',
|
|
36
|
+
'CHANGELOG.md the installed package ships. It reads no registry and needs no',
|
|
37
|
+
'`gh`, so it answers offline.',
|
|
38
|
+
'',
|
|
39
|
+
'The start defaults to the oldest `version` across the target stamp domains,',
|
|
40
|
+
'since the oldest domain is the one furthest behind. --since overrides it.',
|
|
41
|
+
'',
|
|
42
|
+
'Exit codes:',
|
|
43
|
+
' 0 the range was read, empty when the target is already current',
|
|
44
|
+
' 1 refused, with the reason on stderr',
|
|
45
|
+
'',
|
|
46
|
+
'A refusal names a "reason" rather than printing an empty range. no-stamp',
|
|
47
|
+
'means no start was recorded or given, no-changelog means the file or its',
|
|
48
|
+
'release headings are missing, and unknown-version means the start or the',
|
|
49
|
+
'running version is absent from the file, which covers a pre-release and a',
|
|
50
|
+
'start newer than the binary.',
|
|
51
|
+
'',
|
|
52
|
+
'The JSON carries "since", "running", and "entries", each an object with the',
|
|
53
|
+
'release "version" and its raw "markdown". Read the record rather than the',
|
|
54
|
+
'exit when a skill consumes this, since a shell function wrapping the binary',
|
|
55
|
+
'may flatten the status.',
|
|
56
|
+
'',
|
|
57
|
+
'Examples:',
|
|
58
|
+
' canon changelog',
|
|
59
|
+
' canon changelog --since 5.47.0 --json',
|
|
60
|
+
' canon changelog --root ../frameboard',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n')
|
|
63
|
+
|
|
64
|
+
export function register(program: Command): void {
|
|
65
|
+
program
|
|
66
|
+
.command('changelog')
|
|
67
|
+
.description(
|
|
68
|
+
'Print the release entries after a version up to the running one',
|
|
69
|
+
)
|
|
70
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
71
|
+
.option(
|
|
72
|
+
'--since <version>',
|
|
73
|
+
'Start after this version instead of the stamp',
|
|
74
|
+
)
|
|
75
|
+
.option('--root <path>', 'Target whose stamp sets the start', '.')
|
|
76
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
77
|
+
.addHelpText('after', HELP)
|
|
78
|
+
.action((opts: ChangelogOptions) => {
|
|
79
|
+
process.exitCode = runChangelog(opts)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readChangelogText(): string | undefined {
|
|
84
|
+
const path = join(PROJECT_ROOT, 'CHANGELOG.md')
|
|
85
|
+
|
|
86
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stampedVersions(root: string): string[] {
|
|
90
|
+
const stamp = readStamp(root)
|
|
91
|
+
if (stamp === undefined) return []
|
|
92
|
+
|
|
93
|
+
return Object.values(stamp.domains)
|
|
94
|
+
.map((domain) => domain?.version)
|
|
95
|
+
.filter((version): version is string => version !== undefined)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function runChangelog(opts: ChangelogOptions): number {
|
|
99
|
+
intro('canon changelog')
|
|
100
|
+
|
|
101
|
+
const root = resolve(opts.root ?? '.')
|
|
102
|
+
const report = resolveChangelog({
|
|
103
|
+
text: readChangelogText(),
|
|
104
|
+
running: readInstalled().version ?? UNKNOWN_LABEL,
|
|
105
|
+
since: opts.since,
|
|
106
|
+
stamped: stampedVersions(root),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
if (report.kind === 'range') {
|
|
110
|
+
reportRange(report)
|
|
111
|
+
} else {
|
|
112
|
+
reportRefusal(report)
|
|
113
|
+
}
|
|
114
|
+
outro()
|
|
115
|
+
|
|
116
|
+
if (opts.json) {
|
|
117
|
+
process.stdout.write(`${JSON.stringify(report)}\n`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return report.kind === 'range' ? 0 : 1
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function reportRange(
|
|
124
|
+
report: Extract<ChangelogReport, { kind: 'range' }>,
|
|
125
|
+
): void {
|
|
126
|
+
logStep('Range')
|
|
127
|
+
logInfo(`${report.since} to ${report.running}`)
|
|
128
|
+
|
|
129
|
+
if (report.entries.length === 0) {
|
|
130
|
+
logInfo('No release after the start, so nothing is pending')
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
logStep(plural(report.entries.length, 'release'))
|
|
135
|
+
for (const entry of report.entries) {
|
|
136
|
+
pipeOutput(entry.markdown)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function reportRefusal(
|
|
141
|
+
report: Extract<ChangelogReport, { kind: 'refused' }>,
|
|
142
|
+
): void {
|
|
143
|
+
logStep('Refused')
|
|
144
|
+
const version = report.version === undefined ? '' : ` (${report.version})`
|
|
145
|
+
logWarn(`${report.reason}${version}: ${REFUSALS[report.reason]}`)
|
|
146
|
+
}
|
package/src/commands/hooks.ts
CHANGED
|
@@ -39,14 +39,16 @@ export function register(program: Command): void {
|
|
|
39
39
|
'Runs these steps in order, each a child call reading its --json record:',
|
|
40
40
|
...Object.values(STEP_VERBS).map((verb) => ` canon ${verb.join(' ')}`),
|
|
41
41
|
'',
|
|
42
|
-
'The archive
|
|
43
|
-
'Lines go to stderr, and an ordinary merge
|
|
42
|
+
'The archive and the member retirement run once per pull request',
|
|
43
|
+
'number the merge brought in. Lines go to stderr, and an ordinary merge',
|
|
44
|
+
'prints nothing.',
|
|
44
45
|
'',
|
|
45
|
-
'The reclaim
|
|
46
|
-
'inside a linked worktree never removes the
|
|
46
|
+
'The member retirement and the reclaim run from the working directory',
|
|
47
|
+
'with no root, so a pull inside a linked worktree never removes the',
|
|
48
|
+
'worktree it stands in.',
|
|
47
49
|
'',
|
|
48
50
|
'Environment:',
|
|
49
|
-
' CANON_SKIP_RECLAIM=1 skip the
|
|
51
|
+
' CANON_SKIP_RECLAIM=1 skip the member retirement and the reclaim',
|
|
50
52
|
' CANON_SKIP_UPGRADE=1 skip the CLI reinstall',
|
|
51
53
|
' CANON_SKIP_PLUGIN_UPDATE=1 skip the plugin update',
|
|
52
54
|
'',
|
package/src/commands/labels.ts
CHANGED
|
@@ -249,8 +249,8 @@ export function register(program: Command): void {
|
|
|
249
249
|
'## Summary or ## Key changes, fails. A review, a release-please body,',
|
|
250
250
|
'and a scan carrying no title skip this check.',
|
|
251
251
|
'',
|
|
252
|
-
'It
|
|
253
|
-
'
|
|
252
|
+
'It fails the density of that same body, reported per section under',
|
|
253
|
+
'`density`: a bullet past 200 characters, more than 5 plain',
|
|
254
254
|
'bullets or 2 paragraphs in a row, a paragraph past 350 characters, any',
|
|
255
255
|
'H3 or deeper heading, and Technical context mixing bullets with',
|
|
256
256
|
'paragraphs. A bold label alone on a line splits a Key changes run, a',
|
|
@@ -279,11 +279,12 @@ export function register(program: Command): void {
|
|
|
279
279
|
'not read, as GitHub does not read it.',
|
|
280
280
|
'',
|
|
281
281
|
'Exit codes:',
|
|
282
|
-
' 0 none of the
|
|
282
|
+
' 0 none of the nine found',
|
|
283
283
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
284
284
|
' 2 the title or body carries a phase label, a board identifier, a',
|
|
285
285
|
' session link, a banned character, a heading outside the closed',
|
|
286
|
-
' list or a required one missing, a
|
|
286
|
+
' list or a required one missing, a density finding in the body, a',
|
|
287
|
+
' missing or stray closing line',
|
|
287
288
|
' under --closes, or a title',
|
|
288
289
|
" breaking claude/skills/git-pr/references/pr.md's format, casing, or length",
|
|
289
290
|
'',
|
|
@@ -718,9 +719,9 @@ async function runScan(opts: ScanOptions): Promise<number> {
|
|
|
718
719
|
}
|
|
719
720
|
}
|
|
720
721
|
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
722
|
+
// A body's density fails the scan. A post's never does, since the review
|
|
723
|
+
// post hook denies on exit 2 and scans with no title, so a post-density
|
|
724
|
+
// fail would block every dense review or reply post.
|
|
724
725
|
const post = !sectionsChecked && isPost(resolved.body)
|
|
725
726
|
const density = sectionsChecked
|
|
726
727
|
? bodyDensity(resolved.body)
|
|
@@ -859,6 +860,7 @@ async function runScan(opts: ScanOptions): Promise<number> {
|
|
|
859
860
|
titleFormatIssues.length === 0 &&
|
|
860
861
|
sections.outside.length === 0 &&
|
|
861
862
|
sections.missing.length === 0 &&
|
|
863
|
+
!(sectionsChecked && density.length > 0) &&
|
|
862
864
|
banFindings.length === 0 &&
|
|
863
865
|
closing.missing.length === 0 &&
|
|
864
866
|
closing.stray.length === 0
|