@natjswenson/shipflow 0.3.2 → 0.4.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 +131 -15
- package/README.md +107 -26
- package/SKILL.md +99 -1
- package/bin/shipflow.js +124 -0
- package/lib/apply.mjs +8 -1
- package/lib/release.mjs +813 -0
- package/package.json +1 -1
- package/skill-invariants.json +21 -1
- package/templates/dev-main-promotion/dev-to-main-automerge.yml.tmpl +14 -3
- package/templates/gitflow/hotfix-automerge.yml.tmpl +11 -3
- package/templates/gitflow/hotfix-merge-back.yml.tmpl +8 -2
- package/templates/gitflow/release-automerge.yml.tmpl +11 -3
- package/templates/gitflow/release-merge-back.yml.tmpl +8 -2
- package/templates/github-flow/main-automerge.yml.tmpl +11 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,21 +2,137 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@natjswenson/shipflow` are documented here.
|
|
4
4
|
|
|
5
|
-
## 0.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
5
|
+
## 0.4.0 (2026-08-02) — release one named thing, and prove the tag exists
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Component releases: `release-status`, `release-prepare`, `release-cut`.**
|
|
10
|
+
A *component* is one independently-versioned thing in a repo — a skill in a
|
|
11
|
+
monorepo, or the repo itself. `release.componentLayout` says where its
|
|
12
|
+
version files, changelog, tag and release workflow live, with `{name}` as the
|
|
13
|
+
only substitution token, and `release.components` lists the names. A repo
|
|
14
|
+
with neither gets a single component inferred from its root, so a one-project
|
|
15
|
+
repo needs no config at all and `--component` may be omitted.
|
|
16
|
+
|
|
17
|
+
This closes the half of releasing that was never automated. The mechanical
|
|
18
|
+
end already worked — `_release.yml` is version-driven and idempotent, every
|
|
19
|
+
caller has `workflow_dispatch`, `release-dispatch` exists. Everything
|
|
20
|
+
*upstream* of the dispatch was manual, and that is where the friction and the
|
|
21
|
+
mistakes lived.
|
|
22
|
+
|
|
23
|
+
- **`release-status` reads state instead of guessing it.** The version on main
|
|
24
|
+
and on dev (read via `git show`, so the user's working tree is never touched
|
|
25
|
+
or checked out), the last tag, every commit since that tag that touched this
|
|
26
|
+
component's paths, a suggested bump with its reason, and a `statusHash` that
|
|
27
|
+
`release-cut` requires back — the same TOCTOU discipline `apply` already has.
|
|
28
|
+
|
|
29
|
+
- **`collateral`: every other component the same promotion would release.** A
|
|
30
|
+
`dev → main` promotion is atomic and carries all of dev, so cutting a release
|
|
31
|
+
for one component also releases anything else sitting bumped-but-untagged
|
|
32
|
+
there. The SKILL.md rule is that this list is named to the user before the
|
|
33
|
+
irreversible step, never merely present in JSON an agent might skim past.
|
|
34
|
+
|
|
35
|
+
- **`release-cut` is resumable, bounded, and proves the tag.** The full path —
|
|
36
|
+
feature PR, checks, merge, promotion, auto-merge, release run, tag — takes
|
|
37
|
+
longer than one call should block for, so each call advances as far as it can
|
|
38
|
+
and returns the stage it is parked at. Every stage is derived from live remote
|
|
39
|
+
state, never from a record of a previous call, so a resumed run and a fresh
|
|
40
|
+
one are the same code path. It reports `done: true` only after reading the tag
|
|
41
|
+
back from origin: a dispatched workflow, a merged PR and a green check are all
|
|
42
|
+
still "not done."
|
|
43
|
+
|
|
44
|
+
- **Version files can be `package.json`, `SKILL.md` frontmatter, `pyproject.toml`
|
|
45
|
+
or a top-level `project.yml`.** A component model that only reads
|
|
46
|
+
`package.json` is not generic, it is a node model — the first two non-node
|
|
47
|
+
repos this was pointed at were a Python project and an Xcode project. TOML and
|
|
48
|
+
YAML are matched at column zero only: both formats nest, and an indented
|
|
49
|
+
`version` is a dependency pin, not the project's own version. Releasing the
|
|
50
|
+
wrong number is worse than reporting none.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **`spliceChangelog` built a regex out of the version string and escaped only
|
|
55
|
+
the dots**, leaving `\`, `*`, `+`, `(` and `[` live all the way into
|
|
56
|
+
`new RegExp`. `prepare` rejects a non-semver version before reaching it, so
|
|
57
|
+
this was not exploitable through the CLI — but the function is exported and
|
|
58
|
+
independently callable, and a guard that lives in the caller is not a guard.
|
|
59
|
+
It now matches with plain string operations, which is also exactly what
|
|
60
|
+
`_release.yml`'s `awk` does (`/^## / && index($0, ver)`), so the duplicate
|
|
61
|
+
check and the release-time extractor answer the same question the same way.
|
|
62
|
+
Found by CodeQL (`js/incomplete-sanitization`, high) on PR #158.
|
|
63
|
+
|
|
64
|
+
- **`dispatchReleaseWorkflow` ignored the `ownerRepo` it was given.** It shelled
|
|
65
|
+
out to `gh workflow run` with no `--repo`, so `gh` inferred the repository
|
|
66
|
+
from the process's working directory — which is routinely *not* the repo
|
|
67
|
+
`--repo <path>` points at. A dispatch into the wrong repository still exits 0,
|
|
68
|
+
so this failed silently in exactly the setup the flag exists for.
|
|
69
|
+
|
|
70
|
+
### Notes
|
|
71
|
+
|
|
72
|
+
- A breaking change on a component still in 0.x is capped at a **minor** bump,
|
|
73
|
+
and reported as capped rather than applied silently. Declaring 1.0.0 is an
|
|
74
|
+
API-stability promise, and no commit message is entitled to make it on the
|
|
75
|
+
maintainer's behalf.
|
|
76
|
+
- `release-prepare` does its work in a throwaway `git worktree`, so unrelated
|
|
77
|
+
uncommitted work in the user's tree cannot be swept into a release commit.
|
|
78
|
+
Real trees have parallel work in them; this monorepo's had an entire untracked
|
|
79
|
+
skill in it while this feature was written.
|
|
80
|
+
- No template changed, so no rendered workflow and no `renderedTemplateHashes`
|
|
81
|
+
entry moves in this release.
|
|
82
|
+
|
|
83
|
+
## 0.3.3 (2026-08-01) — least-privilege permissions in every rendered workflow
|
|
84
|
+
|
|
85
|
+
- **Every rendered workflow granted its permissions at the workflow level, so
|
|
86
|
+
every job in the file got them whether it needed them or not.** zizmor flags
|
|
87
|
+
this as `excessive-permissions` (High), and it was failing this repo's own
|
|
88
|
+
`security / workflows` gate on `dev-to-main-automerge.yml`. All six templates
|
|
89
|
+
now deny by default (`permissions: {}`) and grant per job.
|
|
90
|
+
|
|
91
|
+
A workflow-level grant is not just untidy — it silently extends to jobs added
|
|
92
|
+
later that were never reviewed for it. Scoping is the fix; waiving the rule
|
|
93
|
+
would have kept the finding *and* the exposure.
|
|
94
|
+
|
|
95
|
+
- **`contents: write` is dropped from the auto-merge workflows entirely, not
|
|
96
|
+
moved.** `gh pr merge --auto` only *enables* native auto-merge, which is a
|
|
97
|
+
pull-requests operation; GitHub performs the merge itself afterwards under its
|
|
98
|
+
own automation rather than this token. Those workflows now grant
|
|
99
|
+
`pull-requests: write` to the two jobs that call `gh`, and nothing else.
|
|
100
|
+
|
|
101
|
+
Where `release.releaseCredential` names a real PAT (the supported setup), the
|
|
102
|
+
workflow token's permissions never applied to those steps in the first place.
|
|
103
|
+
These grants are what matters for a repo that left the credential defaulted to
|
|
104
|
+
`GITHUB_TOKEN`.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **The gitflow merge-back workflows could not open their own fallback PR.**
|
|
109
|
+
`release-merge-back` and `hotfix-merge-back` granted only `contents: write`,
|
|
110
|
+
but their "on any failure, open a PR for manual resolution" step calls
|
|
111
|
+
`gh pr create` — which needs `pull-requests: write`. Under a defaulted
|
|
112
|
+
`GITHUB_TOKEN` that step would have failed exactly when it was needed most:
|
|
113
|
+
after a merge conflict, with nothing else left to surface it. Found while
|
|
114
|
+
scoping the permissions above, not by a report.
|
|
115
|
+
|
|
116
|
+
## 0.3.2 (2026-07-28) — attempted npm-page fix; did NOT work
|
|
117
|
+
|
|
118
|
+
- **Attempted and failed: making npmjs.com render the README.** 0.3.1 put
|
|
119
|
+
README.md, LICENSE and CHANGELOG.md into the tarball (verified by downloading
|
|
120
|
+
the published artifact), which fixed what `npm install` delivers. This release
|
|
121
|
+
additionally staged those files into the package directory before
|
|
122
|
+
`npm publish` was invoked, on the theory that npm populates the registry
|
|
123
|
+
manifest's `readme` field from disk at that point. **It did not work.** After
|
|
124
|
+
publishing, `npm view <pkg> readme` still returns the literal string
|
|
125
|
+
`ERROR: No README data found!` for both packages.
|
|
126
|
+
That field appears to be sticky: it was set once by an early release that had
|
|
127
|
+
no README, and republishing with one present does not overwrite it. Since
|
|
128
|
+
npmjs.com only falls back to reading the tarball when the field is *absent*
|
|
129
|
+
(compare `zod`, which renders fine with none at all), the stale error string
|
|
130
|
+
keeps winning.
|
|
131
|
+
The ineffective staging step was reverted in the release workflow; the
|
|
132
|
+
`prepack` hook stays, because the tarball fix is real and verified. **The npm
|
|
133
|
+
web page for both packages is still blank** — an open issue, likely needing
|
|
134
|
+
npm support to clear the cached field.
|
|
135
|
+
Code unchanged in both skills.
|
|
20
136
|
|
|
21
137
|
## 0.3.1 (2026-07-28) — publish the README, LICENSE and CHANGELOG to npm
|
|
22
138
|
|
package/README.md
CHANGED
|
@@ -1,47 +1,105 @@
|
|
|
1
1
|
# shipflow
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
<!-- >>> press:masthead v0.9.0 sha256:2c0f2d9aea4a GENERATED by @natjswenson/press, do not edit -->
|
|
4
|
+
**NS** · NATE SWENSON · CLAUDE CODE SKILL · PRESS v0.9.0 · linkedin.com/in/natejswenson
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
---
|
|
7
|
+
<!-- <<< press:masthead -->
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
*Scaffold branching, auto-merge, branch cleanup and release tagging into any repo.*
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
> **Nothing is mutated that the plan did not show, and nothing is mutated before you confirm it.**
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|---|---|
|
|
14
|
-
| `dev-main-promotion` | Long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main` |
|
|
15
|
-
| `github-flow` | Single long-lived `main`; every PR merges (and auto-merges) directly to it |
|
|
16
|
-
| `gitflow` | `develop` + `main` + transient `release/*`/`hotfix/*` branches, for software maintaining multiple released versions concurrently |
|
|
13
|
+
[](https://www.npmjs.com/package/@natjswenson/shipflow) [](./LICENSE)
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
## Why install this
|
|
19
16
|
|
|
20
|
-
|
|
17
|
+
Branch protection, auto-merge and release tagging are the settings everybody
|
|
18
|
+
configures once, by hand, in a web UI, and then cannot answer questions about six
|
|
19
|
+
months later. Nothing records what was intended, so drift is undetectable.
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
shipflow makes the policy a committed file. Run it in a target repo and it
|
|
22
|
+
detects existing branch protection, CI checks, release conventions and which
|
|
23
|
+
branching pattern the repo already uses, shows you a plan, and only mutates
|
|
24
|
+
anything after you confirm. The skill package is identical everywhere; the actual
|
|
25
|
+
policy lives in the target repo's own `.github/shipflow.json`, auditable in a
|
|
26
|
+
diff.
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
It supports three patterns rather than imposing one, and `detect` scores all
|
|
29
|
+
three against the repo's real shape — it never silently picks one.
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
## What you get
|
|
32
|
+
|
|
33
|
+
| Path | What it provides |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `skills/shipflow/SKILL.md` | The interactive setup interview, and where it must stop and ask. |
|
|
36
|
+
| `skills/shipflow/bin/` | The CLI: `detect`, `plan`, `apply`, `releases`, `release-dispatch`. |
|
|
37
|
+
| `skills/shipflow/templates/` | The workflow files each pattern renders. |
|
|
38
|
+
| `skills/shipflow/skill-invariants.json` | The prose guardrails and the baseline eval declaration. |
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
30
41
|
|
|
31
42
|
```sh
|
|
32
43
|
npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
|
|
33
44
|
```
|
|
34
45
|
|
|
35
|
-
> **Always pin `@latest`.** Without an explicit version
|
|
46
|
+
> **Always pin `@latest`.** Without an explicit version or tag, `npx` can
|
|
47
|
+
> silently resolve a stale install already on your `PATH` instead of fetching the
|
|
48
|
+
> current version from the registry, with no warning. This cost this very repo a
|
|
49
|
+
> release: a bare invocation ran a stale global 0.2.0, missing every fix through
|
|
50
|
+
> 0.2.5 including a Critical template-injection fix. If you have ever run
|
|
51
|
+
> `npm install -g @natjswenson/shipflow`, remove it.
|
|
52
|
+
|
|
53
|
+
## Triggers
|
|
54
|
+
|
|
55
|
+
- Setting up branch protection standards on a repo.
|
|
56
|
+
- Applying deployment or release standards to a repo.
|
|
57
|
+
- Wanting long-lived `dev`/`main` branches with auto-merge and branch cleanup.
|
|
58
|
+
- "why did this PR not auto-merge", or a release that should have been tagged and
|
|
59
|
+
was not.
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- Node 18+.
|
|
64
|
+
- [`gh`](https://cli.github.com/), authenticated with admin rights on the target
|
|
65
|
+
repo — branch protection cannot be read or written without them.
|
|
66
|
+
- A GitHub repo. Deletion rulesets need GitHub Pro or a public repo; on a private
|
|
67
|
+
free-tier repo that call returns 403 and shipflow reports it rather than
|
|
68
|
+
pretending it applied.
|
|
36
69
|
|
|
37
|
-
|
|
70
|
+
## Patterns
|
|
71
|
+
|
|
72
|
+
| Pattern | Shape |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `dev-main-promotion` | Long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main` |
|
|
75
|
+
| `github-flow` | Single long-lived `main`; every PR merges (and auto-merges) directly to it |
|
|
76
|
+
| `gitflow` | `develop` + `main` + transient `release/*`/`hotfix/*`, for software maintaining multiple released versions concurrently |
|
|
77
|
+
|
|
78
|
+
`detect` scores all three against the repo's branches, tags and workflow files,
|
|
79
|
+
then either confirms a confident match with you or asks you to pick when
|
|
80
|
+
detection is ambiguous or the repo is greenfield.
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
1. **`/shipflow` in Claude Code** runs an interactive setup interview — detects
|
|
85
|
+
the pattern, branch protection, CI and the default branch, confirms them (plus
|
|
86
|
+
`requiredChecks` and `protectionOwner`) with you, and writes
|
|
87
|
+
`.github/shipflow.json`.
|
|
88
|
+
2. **`shipflow plan`** diffs that config against live repo state and shows
|
|
89
|
+
exactly what would change, before anything is touched.
|
|
90
|
+
3. **`shipflow apply`** — only after you confirm — renders the resolved pattern's
|
|
91
|
+
workflow files and makes the confirmed mutations. Nothing happens outside what
|
|
92
|
+
the plan showed.
|
|
93
|
+
4. Ongoing: promotions auto-merge once required checks pass; a durable
|
|
94
|
+
`release-pending` label survives the async gap until a later
|
|
95
|
+
`shipflow releases` check asks whether to cut a release.
|
|
38
96
|
|
|
39
97
|
## Commands
|
|
40
98
|
|
|
41
99
|
| Command | What it does |
|
|
42
100
|
|---|---|
|
|
43
101
|
| `detect --repo <path> [--main <name>] [--dev <name>]` | Inspect live repo state: branch protection, CI checks, release conventions |
|
|
44
|
-
| `plan --repo <path>` | Diff `.github/shipflow.json` against live state; prints what would change
|
|
102
|
+
| `plan --repo <path>` | Diff `.github/shipflow.json` against live state; prints what would change plus a state hash |
|
|
45
103
|
| `apply --repo <path> --expect-state-hash <hash> [--dry-run] [--force <id> --force-reason <text>]` | Apply a confirmed plan |
|
|
46
104
|
| `releases --repo <path>` | List `dev → main` promotions still labeled `release-pending` |
|
|
47
105
|
| `release-dispatch --repo <path> --pr <n> --workflow-file <f>... --ref <ref>` | Dispatch each changed skill's release workflow; clear the label on success |
|
|
@@ -51,16 +109,39 @@ Every command prints JSON to stdout.
|
|
|
51
109
|
|
|
52
110
|
## Status
|
|
53
111
|
|
|
54
|
-
**`release.mode: "manual-gate"`**
|
|
112
|
+
**`release.mode: "manual-gate"`** — the only implemented mode — is live-validated
|
|
113
|
+
end to end, dogfooded on this repo and on an external repo
|
|
114
|
+
(`natejswenson/1.00s`). A full Siege security audit found and fixed 9 findings (1
|
|
115
|
+
Critical, 1 High, the rest Medium/Low) before wider rollout; zero Critical or High
|
|
116
|
+
findings remain open.
|
|
55
117
|
|
|
56
|
-
**`release.mode: "auto"`** (fully automatic tagging via `release-please`) is
|
|
118
|
+
**`release.mode: "auto"`** (fully automatic tagging via `release-please`) is
|
|
119
|
+
accepted in the config schema but not yet implemented — `apply` refuses with a
|
|
120
|
+
clear error until it ships.
|
|
57
121
|
|
|
58
122
|
## Design
|
|
59
123
|
|
|
60
|
-
[`
|
|
124
|
+
- [`2026-07-14-shipflow-skill-design.md`](../../docs/plans/2026-07-14-shipflow-skill-design.md)
|
|
125
|
+
— the original single-pattern design (7 rounds of adversarial review, score 12 → 0).
|
|
126
|
+
- [`2026-07-16-shipflow-multi-pattern-design.md`](../../docs/plans/2026-07-16-shipflow-multi-pattern-design.md)
|
|
127
|
+
— the multi-pattern registry design (10 rounds of adversarial review).
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cd skills/shipflow/skills/shipflow
|
|
133
|
+
npm test
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Node skill. `ci / shipflow` runs the same tests plus the house lints on every
|
|
137
|
+
pull request. The baseline is pinned against this repo's own `shipflow.json` and
|
|
138
|
+
the workflow it renders, byte-exact — the rendered file *is* the contract.
|
|
139
|
+
|
|
140
|
+
## Changelog
|
|
61
141
|
|
|
62
|
-
[`
|
|
142
|
+
See [`CHANGELOG.md`](CHANGELOG.md). Releases are cut by a version bump, tagged
|
|
143
|
+
`shipflow-v<version>`, and published to npm.
|
|
63
144
|
|
|
64
145
|
## License
|
|
65
146
|
|
|
66
|
-
MIT
|
|
147
|
+
MIT — see [`LICENSE`](LICENSE).
|
package/SKILL.md
CHANGED
|
@@ -32,6 +32,7 @@ user; the CLI is the only thing that *does*.
|
|
|
32
32
|
| `.github/shipflow.json` doesn't exist in the target repo yet | **First-run setup** |
|
|
33
33
|
| `.github/shipflow.json` exists, user wants to check/repair drift | **Re-run / audit** |
|
|
34
34
|
| User asks "any releases pending?" / periodic check-in / after a `dev → main` merge | **Check pending releases** |
|
|
35
|
+
| User wants to cut a release for one named thing ("release devlog") | **Cut a component release** |
|
|
35
36
|
|
|
36
37
|
## First-run setup
|
|
37
38
|
|
|
@@ -62,7 +63,31 @@ user; the CLI is the only thing that *does*.
|
|
|
62
63
|
|
|
63
64
|
4. **Confirm branch names and required checks with the user.** Show `workflows.jobNames` from the detect output as candidate `requiredChecks` (this list is already filtered to jobs from workflows that actually trigger on `pull_request` — a job that only runs on `schedule`/`workflow_dispatch` can never satisfy a required check, so it's never offered as a candidate) and let the user confirm/edit the list. **An empty `requiredChecks` list is a fail-open state, not a valid steady state** — `shipflow apply` will hard-refuse to enable auto-merge with zero required checks (see Error handling below). Don't let the user skip this without understanding that consequence.
|
|
64
65
|
|
|
65
|
-
**If the candidate list is empty,
|
|
66
|
+
**If the candidate list is empty, a CI workflow has to exist before auto-merge
|
|
67
|
+
can be enabled. Hand that job to the `ghfactory` skill** — authoring and *verifying*
|
|
68
|
+
workflow YAML is its whole subject, and it does things shipflow never will:
|
|
69
|
+
it resolves every action ref against the real API (no linter checks that an
|
|
70
|
+
action exists), validates each `with:` key against the action's own
|
|
71
|
+
`action.yml`, reports how many majors behind each pin is, and runs actionlint
|
|
72
|
+
and zizmor before showing you anything. Two skills answering "scaffold me a CI
|
|
73
|
+
workflow" differently is worse than either answer.
|
|
74
|
+
|
|
75
|
+
> Use the ghfactory skill to create a `pull_request`-triggered build+test workflow
|
|
76
|
+
> for this repo, then come back here with the job name.
|
|
77
|
+
|
|
78
|
+
**If ghfactory is not installed**, draft it here instead: investigate the repo
|
|
79
|
+
directly (`package.json`, `Cargo.toml`, `project.yml`/`.xcodeproj`, `go.mod`,
|
|
80
|
+
`pyproject.toml`, or whatever's actually there) and write a minimal,
|
|
81
|
+
conservative `pull_request`-triggered build+test workflow.
|
|
82
|
+
**Never silently overwrite an existing workflow file.** Present it and wait for
|
|
83
|
+
explicit confirmation before writing it — the same confirm-before-write pattern
|
|
84
|
+
as everything else in this skill.
|
|
85
|
+
|
|
86
|
+
Either way, say plainly that a fresh workflow is a starting point, not a
|
|
87
|
+
guarantee it's green on the first run — **a required check that never passes
|
|
88
|
+
blocks every future merge**, so the user should watch it run successfully
|
|
89
|
+
before relying on it as one. Once it exists, re-run step 1's `detect` (repo
|
|
90
|
+
state changed) and continue this step with the new job name as a real candidate.
|
|
66
91
|
|
|
67
92
|
5. **Resolve `protectionOwner`:**
|
|
68
93
|
- `"external"` → tell the user which settings-as-code artifact was found (`settingsAsCodeArtifact` in the detect output) and that shipflow will defer to it, managing only cleanup/automerge/release, not installing a competing ruleset.
|
|
@@ -116,6 +141,70 @@ This is a **separate, later invocation** from the one that ran the promotion's `
|
|
|
116
141
|
|
|
117
142
|
4. If no, leave the label as-is — there is no "defer" state in this version; declining is final for that promotion short of a manual dispatch. (Deliberate v1 simplification, not an oversight.)
|
|
118
143
|
|
|
144
|
+
## Cut a component release
|
|
145
|
+
|
|
146
|
+
For the conversational "release devlog" flow, prefer the **`release` skill** — it owns the
|
|
147
|
+
bump judgment, the CHANGELOG prose and the run presentation. This section is the CLI contract
|
|
148
|
+
underneath it, and the fallback when that skill is not installed.
|
|
149
|
+
|
|
150
|
+
A **component** is one independently-versioned thing in a repo: a skill in a monorepo, or the
|
|
151
|
+
repo itself. `release.componentLayout` describes where a component's version, changelog, tag
|
|
152
|
+
and release workflow live, with `{name}` as the only substitution token;
|
|
153
|
+
`release.components` lists the names. A repo with neither gets a single component inferred from
|
|
154
|
+
its root (`package.json`, `CHANGELOG.md`, `v{version}`), so a one-project repo needs no config
|
|
155
|
+
at all and `--component` may be omitted.
|
|
156
|
+
|
|
157
|
+
1. **Read the state. Never guess it.**
|
|
158
|
+
```
|
|
159
|
+
npx -y @natjswenson/shipflow@latest release-status --repo <path> --component <name>
|
|
160
|
+
```
|
|
161
|
+
Returns `state`, the version on main and dev, the last tag, every commit since that tag that
|
|
162
|
+
touched this component's paths, a `suggestedBump` with its reason, `blockers`, `notes`, and a
|
|
163
|
+
`statusHash`. `state` decides the path:
|
|
164
|
+
- `clean` — the released version is what's on main. A bump is needed: go to step 2.
|
|
165
|
+
- `untagged-bump-on-main` — the bump is already on main and was never tagged (a cancelled or
|
|
166
|
+
failed release run). **No PR is needed** — `release-cut` dispatches and verifies. Skip to step 3.
|
|
167
|
+
- `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.
|
|
168
|
+
- `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;
|
|
169
|
+
this means a tag was cut from something other than main, and guessing is how it gets worse.
|
|
170
|
+
|
|
171
|
+
2. **Show the user `collateral`, `blockers` and the proposed version, and wait.**
|
|
172
|
+
**`collateral` is not advisory.** A `dev → main` promotion is atomic and carries all of dev, so
|
|
173
|
+
every component listed there is released by the same promotion — "release devlog" really does
|
|
174
|
+
also release them. **Never run `release-cut` without naming that list to the user first.**
|
|
175
|
+
Releasing something the user did not ask for is the worst thing this flow can do.
|
|
176
|
+
|
|
177
|
+
`suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a
|
|
178
|
+
breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a
|
|
179
|
+
release decision, never a commit message's. Then:
|
|
180
|
+
```
|
|
181
|
+
npx -y @natjswenson/shipflow@latest release-prepare --repo <path> --component <name> \
|
|
182
|
+
--version <x.y.z> --notes-file <path>
|
|
183
|
+
```
|
|
184
|
+
Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work
|
|
185
|
+
in the user's tree is untouched and cannot be swept into the release commit. The version bump
|
|
186
|
+
and the CHANGELOG entry land in **one commit**, because releases are publish-on-merge — a
|
|
187
|
+
follow-up promotion to fix release notes is too late, the tag is already cut.
|
|
188
|
+
|
|
189
|
+
3. **Cut it, and prove it.**
|
|
190
|
+
```
|
|
191
|
+
npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
|
|
192
|
+
--expect-status-hash <hash-from-step-1> --wait 240
|
|
193
|
+
```
|
|
194
|
+
`--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
|
|
195
|
+
`--skip-hash-check` is a named escape hatch, never a default.
|
|
196
|
+
|
|
197
|
+
**`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full
|
|
198
|
+
path — feature PR, checks, merge, promotion, auto-merge, release run, tag — takes longer than
|
|
199
|
+
one call should block for. Each call advances as far as it can, then returns the `stage` it is
|
|
200
|
+
parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It derives every
|
|
201
|
+
stage from live remote state and never from a record of what a previous call did, so a resumed
|
|
202
|
+
run and a fresh one are the same code path.
|
|
203
|
+
|
|
204
|
+
4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
|
|
205
|
+
from origin. A dispatched workflow, a merged PR and a green check are **not** a release —
|
|
206
|
+
`release-cut` confirms the tag exists on the remote before it says done, and so must you.
|
|
207
|
+
|
|
119
208
|
## Auto mode (not yet implemented)
|
|
120
209
|
|
|
121
210
|
`release.mode: "auto"` is a valid value in the config schema (the full design covers automatic tagging via `release-please`), but `shipflow apply` in this version **refuses to run** against a config with `release.mode: "auto"`, with a clear error rather than silently no-oping. If a user asks for fully automatic tagging, tell them it's designed but not yet shipped (see `CHANGELOG.md`) and that `"manual-gate"` — the deliberate ask-before-tagging flow above — is what's available today.
|
|
@@ -129,6 +218,15 @@ This is a **separate, later invocation** from the one that ran the promotion's `
|
|
|
129
218
|
- **`release.releaseCredential` left as (or defaulted to) `GITHUB_TOKEN`:** auto-merge and the required-check gate still work, but `label-release-pending` will silently never run — a `GITHUB_TOKEN`-attributed auto-merge's `pull_request: closed` event never triggers it, so no promotion will ever surface via `shipflow releases`. This fails silently, not loudly — there's no error to catch it — so it must be caught at setup time (step 5) rather than discovered later. If a user reports "releases never show up," check this first.
|
|
130
219
|
- **`--expect-state-hash is required` refusal:** a real apply was attempted with neither `--expect-state-hash` nor `--skip-hash-check`. Go back and get (or re-fetch via `plan`) the hash — don't reach for `--skip-hash-check` just to make the error go away; that flag exists for a deliberate, documented exception, not as a default workaround.
|
|
131
220
|
- **`--force was passed without --force-reason` refusal:** a `--force` flag was about to be sent with no accompanying justification. Stop and get (or write) an explicit reason tied to what the user actually confirmed before retrying — never pass a placeholder string just to satisfy the flag.
|
|
221
|
+
- **`release-cut` returns `done: false`:** not an error. It is parked at the `stage` it reports,
|
|
222
|
+
waiting on something remote. Call it again with the same arguments. Do not report a release.
|
|
223
|
+
- **`release-status` reports `component-files-dirty`:** this component's own version files or
|
|
224
|
+
CHANGELOG have uncommitted edits, so a bump would collide with them. Unrelated dirt elsewhere in
|
|
225
|
+
the tree is reported under `notes` and is deliberately **not** a blocker — `prepare` runs in an
|
|
226
|
+
isolated worktree specifically so other people's in-flight work is safe.
|
|
227
|
+
- **`release-status` reports `version-unreadable-on-main`:** the component's version files do not
|
|
228
|
+
exist on main, or they disagree with each other. A disagreement is a hard refusal, never a
|
|
229
|
+
"pick the highest" — releasing from a disagreeing set tags one version and ships another.
|
|
132
230
|
- **A `gh`/`git` call hangs or times out:** every subprocess call has a 30-second timeout (`ETIMEDOUT` surfaces in the error message). A timeout on `detect`/`plan` usually means a real GitHub outage or rate-limit — retry once, and if it persists, tell the user rather than looping silently.
|
|
133
231
|
|
|
134
232
|
## Security rules
|
package/bin/shipflow.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '../lib/apply.mjs';
|
|
17
17
|
import { readFileCapped } from '../lib/gh.mjs';
|
|
18
18
|
import { resolvePattern, scoreAll } from '../lib/pattern-registry.mjs';
|
|
19
|
+
import { readStatus, prepare, cut, listComponentNames } from '../lib/release.mjs';
|
|
19
20
|
|
|
20
21
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
22
|
|
|
@@ -244,6 +245,117 @@ function cmdReleaseDispatch(args) {
|
|
|
244
245
|
printJson({ dispatched: results, labelCleared: cleared.ok, labelClearError: cleared.ok ? null : cleared.error });
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
// --- component releases -----------------------------------------------------
|
|
249
|
+
// Shared resolution for the three release-* commands. `--component` is optional
|
|
250
|
+
// when the repo has exactly one component (the inferred single-component case),
|
|
251
|
+
// because in a repo with one thing to release, naming it is ceremony.
|
|
252
|
+
function resolveReleaseArgs(values, commandName) {
|
|
253
|
+
if (!values.repo) return { error: `${commandName}: --repo is required` };
|
|
254
|
+
const configPath = values.config ?? defaultConfigPath(values.repo);
|
|
255
|
+
let config;
|
|
256
|
+
try {
|
|
257
|
+
config = readConfig(configPath);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return { error: `${commandName}: could not read config at ${configPath}: ${e.message}` };
|
|
260
|
+
}
|
|
261
|
+
const names = listComponentNames(config, values.repo);
|
|
262
|
+
let name = values.component;
|
|
263
|
+
if (!name) {
|
|
264
|
+
if (names.length !== 1) {
|
|
265
|
+
return { error: `${commandName}: --component is required (this repo declares ${names.length}: ${names.join(', ')})` };
|
|
266
|
+
}
|
|
267
|
+
name = names[0];
|
|
268
|
+
} else if (!names.includes(name)) {
|
|
269
|
+
return { error: `${commandName}: "${name}" is not a declared component. This repo has: ${names.join(', ')}` };
|
|
270
|
+
}
|
|
271
|
+
return { config, name };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function cmdReleaseStatus(args) {
|
|
275
|
+
const { values } = parseArgs({
|
|
276
|
+
args,
|
|
277
|
+
options: { repo: { type: 'string' }, config: { type: 'string' }, component: { type: 'string' } },
|
|
278
|
+
});
|
|
279
|
+
const resolved = resolveReleaseArgs(values, 'release-status');
|
|
280
|
+
if (resolved.error) return fail(resolved.error);
|
|
281
|
+
try {
|
|
282
|
+
printJson(readStatus(values.repo, resolved.config, resolved.name));
|
|
283
|
+
} catch (e) {
|
|
284
|
+
return fail(`release-status: ${e.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function cmdReleasePrepare(args) {
|
|
289
|
+
const { values } = parseArgs({
|
|
290
|
+
args,
|
|
291
|
+
options: {
|
|
292
|
+
repo: { type: 'string' },
|
|
293
|
+
config: { type: 'string' },
|
|
294
|
+
component: { type: 'string' },
|
|
295
|
+
version: { type: 'string' },
|
|
296
|
+
'notes-file': { type: 'string' },
|
|
297
|
+
date: { type: 'string' },
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const resolved = resolveReleaseArgs(values, 'release-prepare');
|
|
301
|
+
if (resolved.error) return fail(resolved.error);
|
|
302
|
+
if (!values.version || !values['notes-file']) {
|
|
303
|
+
return fail('release-prepare: --version and --notes-file are both required');
|
|
304
|
+
}
|
|
305
|
+
let notes;
|
|
306
|
+
try {
|
|
307
|
+
notes = readFileCapped(values['notes-file']);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
return fail(`release-prepare: could not read --notes-file: ${e.message}`);
|
|
310
|
+
}
|
|
311
|
+
if (notes.trim().length === 0) {
|
|
312
|
+
// An empty CHANGELOG entry is how a release ships with notes that say
|
|
313
|
+
// nothing. _release.yml falls back to a bare "<skill> v<version>" title,
|
|
314
|
+
// which looks deliberate and is not.
|
|
315
|
+
return fail('release-prepare: --notes-file is empty — a release with no notes is not a release');
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const result = prepare(values.repo, resolved.config, resolved.name, values.version, notes, {
|
|
319
|
+
date: values.date,
|
|
320
|
+
featureBranchPrefix: resolved.config.featureBranchPrefix,
|
|
321
|
+
});
|
|
322
|
+
if (!result.ok) return fail(`release-prepare: ${result.error}`);
|
|
323
|
+
printJson(result);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return fail(`release-prepare: ${e.message}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function cmdReleaseCut(args) {
|
|
330
|
+
const { values } = parseArgs({
|
|
331
|
+
args,
|
|
332
|
+
options: {
|
|
333
|
+
repo: { type: 'string' },
|
|
334
|
+
config: { type: 'string' },
|
|
335
|
+
component: { type: 'string' },
|
|
336
|
+
'expect-status-hash': { type: 'string' },
|
|
337
|
+
'skip-hash-check': { type: 'boolean', default: false },
|
|
338
|
+
wait: { type: 'string' },
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
const resolved = resolveReleaseArgs(values, 'release-cut');
|
|
342
|
+
if (resolved.error) return fail(resolved.error);
|
|
343
|
+
const ownerRepo = resolveOwnerRepo(values.repo);
|
|
344
|
+
if (!ownerRepo) return fail('release-cut: could not resolve owner/repo from git remote');
|
|
345
|
+
try {
|
|
346
|
+
const result = cut(values.repo, resolved.config, resolved.name, {
|
|
347
|
+
waitSeconds: values.wait ? Number(values.wait) : 240,
|
|
348
|
+
expectStatusHash: values['expect-status-hash'] ?? null,
|
|
349
|
+
skipHashCheck: values['skip-hash-check'],
|
|
350
|
+
ownerRepo,
|
|
351
|
+
});
|
|
352
|
+
if (!result.ok) return fail(`release-cut: ${result.error}${result.currentStatusHash ? ` (current statusHash: ${result.currentStatusHash})` : ''}`);
|
|
353
|
+
printJson(result);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
return fail(`release-cut: ${e.message}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
247
359
|
function cmdRenameDefaultBranch(args) {
|
|
248
360
|
const { values } = parseArgs({
|
|
249
361
|
args,
|
|
@@ -274,6 +386,9 @@ Commands:
|
|
|
274
386
|
apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash> | --skip-hash-check] [--force <id>]... [--force-reason <text>]
|
|
275
387
|
releases --repo <path> [--config <path>]
|
|
276
388
|
release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
|
|
389
|
+
release-status --repo <path> [--component <name>]
|
|
390
|
+
release-prepare --repo <path> [--component <name>] --version <x.y.z> --notes-file <path> [--date <YYYY-MM-DD>]
|
|
391
|
+
release-cut --repo <path> [--component <name>] (--expect-status-hash <hash> | --skip-hash-check) [--wait <seconds>]
|
|
277
392
|
rename-default-branch --repo <path> --branch <current-name> --to <new-name>
|
|
278
393
|
|
|
279
394
|
Every command prints JSON to stdout.`);
|
|
@@ -314,6 +429,15 @@ if (isMain) {
|
|
|
314
429
|
case 'release-dispatch':
|
|
315
430
|
cmdReleaseDispatch(rest);
|
|
316
431
|
break;
|
|
432
|
+
case 'release-status':
|
|
433
|
+
cmdReleaseStatus(rest);
|
|
434
|
+
break;
|
|
435
|
+
case 'release-prepare':
|
|
436
|
+
cmdReleasePrepare(rest);
|
|
437
|
+
break;
|
|
438
|
+
case 'release-cut':
|
|
439
|
+
cmdReleaseCut(rest);
|
|
440
|
+
break;
|
|
317
441
|
case 'rename-default-branch':
|
|
318
442
|
cmdRenameDefaultBranch(rest);
|
|
319
443
|
break;
|
package/lib/apply.mjs
CHANGED
|
@@ -198,8 +198,15 @@ export function clearReleasePendingLabel(ownerRepo, prNumber) {
|
|
|
198
198
|
return r.ok ? { ok: true } : { ok: false, error: r.stderr };
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// `--repo` is not optional here even though `gh` would infer it: the CLI is
|
|
202
|
+
// invoked with `--repo <path>` pointing at an arbitrary target repo, which is
|
|
203
|
+
// routinely NOT the process's cwd. Without it, `gh` resolves the repo from
|
|
204
|
+
// whatever directory the agent happened to be in and dispatches a release in
|
|
205
|
+
// the wrong repository — silently, since a successful dispatch elsewhere still
|
|
206
|
+
// exits 0. ownerRepo was already threaded in for exactly this and was being
|
|
207
|
+
// ignored.
|
|
201
208
|
export function dispatchReleaseWorkflow(ownerRepo, skillWorkflowFile, ref) {
|
|
202
|
-
const r = spawnArgs('gh', ['workflow', 'run', skillWorkflowFile, '--ref', ref]);
|
|
209
|
+
const r = spawnArgs('gh', ['workflow', 'run', skillWorkflowFile, '--ref', ref, '--repo', ownerRepo]);
|
|
203
210
|
return r.status === 0 ? { ok: true } : { ok: false, error: r.stderr };
|
|
204
211
|
}
|
|
205
212
|
|