@natjswenson/shipflow 0.3.3 → 0.6.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 +155 -0
- package/README.md +107 -26
- package/SKILL.md +99 -3
- package/bin/shipflow.js +126 -0
- package/lib/apply.mjs +8 -1
- package/lib/release.mjs +948 -0
- package/package.json +1 -1
- package/skill-invariants.json +34 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,161 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@natjswenson/shipflow` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.6.0 (2026-08-03) — the ambiguous fast path is refused, not guessed
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`release-cut`'s fast path could silently tag the OLDER version.** When a
|
|
10
|
+
component's `main` carried an untagged bump *and* `dev` independently carried
|
|
11
|
+
something higher — `lastTag < main < dev` — `readStatus` collapsed both facts
|
|
12
|
+
into the single `untagged-bump-on-main` state, and `cut()`'s fast path acted
|
|
13
|
+
on that state alone, dispatching a release for whatever sat on `main` while
|
|
14
|
+
the version actually being released sat, unread, on `dev`. Hit for real
|
|
15
|
+
during `/release eval` on 2026-08-03: `main` was at 0.2.1, `dev` at 0.3.0 —
|
|
16
|
+
`cut` would have tagged `eval-v0.2.1` and reported success. Caught only by
|
|
17
|
+
reading `release.mjs` before running the irreversible step.
|
|
18
|
+
|
|
19
|
+
`readStatus` now reports a `devAhead` fact (`{ version, aheadOfMain: true }`)
|
|
20
|
+
independently of `state` — including on a component's never-released first
|
|
21
|
+
bump, the sibling case a fix scoped only to the existing `state` branch would
|
|
22
|
+
have missed — plus a `dev-ahead-of-main` blocker, scoped to exactly the state
|
|
23
|
+
where the fast path is armed. A new pure `resolveReleaseTarget(status,
|
|
24
|
+
requestedVersion)` is the single place the release target is now decided;
|
|
25
|
+
`cut()` calls it once, before any network call, and refuses outright when
|
|
26
|
+
the target is ambiguous, naming both versions. There is no longer a code
|
|
27
|
+
path on which the tag `cut` waits for can disagree with the version it
|
|
28
|
+
decided to release.
|
|
29
|
+
|
|
30
|
+
The refusal is escapable, deliberately not inescapable: `release-cut` gains
|
|
31
|
+
`--version <x.y.z>`, a *confirmation* rather than a bypass — it is only ever
|
|
32
|
+
accepted when it names a version already present on `main` or `dev` in that
|
|
33
|
+
status, so there is no value of it that releases a version which isn't
|
|
34
|
+
actually on the branch being dispatched.
|
|
35
|
+
|
|
36
|
+
## 0.5.0 (2026-08-02) — the merge stops cutting tags; the dispatch is the release
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **`release-cut` now dispatches the component's release workflow itself, after
|
|
41
|
+
the promotion lands.** Previously it merged the promotion and then *waited*
|
|
42
|
+
for a tag that a `push`-triggered job happened to cut. That made a merge the
|
|
43
|
+
real release trigger, which meant any promotion released everything bumped on
|
|
44
|
+
`dev` — whether or not anyone asked, and irreversibly for skills that publish
|
|
45
|
+
to npm.
|
|
46
|
+
|
|
47
|
+
Paired with every caller's `release` job becoming `workflow_dispatch`-only,
|
|
48
|
+
this makes the dispatch the **single point at which a tag is ever created**.
|
|
49
|
+
A `dev → main` merge now moves a version bump to `main` and stops there; the
|
|
50
|
+
component simply becomes `untagged-bump-on-main` until someone releases it on
|
|
51
|
+
purpose.
|
|
52
|
+
|
|
53
|
+
The two halves are load-bearing together. Removing the `push` gate without
|
|
54
|
+
this dispatch leaves `release-cut` waiting forever for a tag nobody cuts;
|
|
55
|
+
adding the dispatch without removing the gate double-releases.
|
|
56
|
+
|
|
57
|
+
- **`collateral` means something smaller and safer now.** It still lists every
|
|
58
|
+
other component whose bump the same promotion moves to `main` — that is
|
|
59
|
+
unavoidable, a promotion is atomic — but those components are no longer
|
|
60
|
+
*released* by it. The disclosure stays because the user should know what their
|
|
61
|
+
promotion moves, and which components are now one dispatch from a release
|
|
62
|
+
nobody asked for.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **`release-status` returned a wrong commit list in a shallow clone, silently.**
|
|
67
|
+
`git log <tag>..<ref>` excludes everything reachable from `<tag>`, and that
|
|
68
|
+
exclusion needs full ancestry. In a grafted history it under-applies, so the
|
|
69
|
+
range returns commits that shipped long ago — without erroring, and therefore
|
|
70
|
+
with a `suggestedBump` derived from fiction.
|
|
71
|
+
|
|
72
|
+
Observed on this repo the day 0.4.0 shipped: a depth-1 checkout of `main`
|
|
73
|
+
reported **1 unreleased commit** for a component a full clone correctly
|
|
74
|
+
reported as **0**, which would have proposed a patch release for nothing.
|
|
75
|
+
`actions/checkout` is depth-1 by default, so any CI job calling `release-status`
|
|
76
|
+
hit this.
|
|
77
|
+
|
|
78
|
+
A shallow repository is now a **blocker**, not a note — every number derived
|
|
79
|
+
from the commit range is untrustworthy, so the honest answer is to refuse and
|
|
80
|
+
say `git fetch --unshallow`, rather than to report a plausible wrong one.
|
|
81
|
+
|
|
82
|
+
## 0.4.0 (2026-08-02) — release one named thing, and prove the tag exists
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- **Component releases: `release-status`, `release-prepare`, `release-cut`.**
|
|
87
|
+
A *component* is one independently-versioned thing in a repo — a skill in a
|
|
88
|
+
monorepo, or the repo itself. `release.componentLayout` says where its
|
|
89
|
+
version files, changelog, tag and release workflow live, with `{name}` as the
|
|
90
|
+
only substitution token, and `release.components` lists the names. A repo
|
|
91
|
+
with neither gets a single component inferred from its root, so a one-project
|
|
92
|
+
repo needs no config at all and `--component` may be omitted.
|
|
93
|
+
|
|
94
|
+
This closes the half of releasing that was never automated. The mechanical
|
|
95
|
+
end already worked — `_release.yml` is version-driven and idempotent, every
|
|
96
|
+
caller has `workflow_dispatch`, `release-dispatch` exists. Everything
|
|
97
|
+
*upstream* of the dispatch was manual, and that is where the friction and the
|
|
98
|
+
mistakes lived.
|
|
99
|
+
|
|
100
|
+
- **`release-status` reads state instead of guessing it.** The version on main
|
|
101
|
+
and on dev (read via `git show`, so the user's working tree is never touched
|
|
102
|
+
or checked out), the last tag, every commit since that tag that touched this
|
|
103
|
+
component's paths, a suggested bump with its reason, and a `statusHash` that
|
|
104
|
+
`release-cut` requires back — the same TOCTOU discipline `apply` already has.
|
|
105
|
+
|
|
106
|
+
- **`collateral`: every other component the same promotion would release.** A
|
|
107
|
+
`dev → main` promotion is atomic and carries all of dev, so cutting a release
|
|
108
|
+
for one component also releases anything else sitting bumped-but-untagged
|
|
109
|
+
there. The SKILL.md rule is that this list is named to the user before the
|
|
110
|
+
irreversible step, never merely present in JSON an agent might skim past.
|
|
111
|
+
|
|
112
|
+
- **`release-cut` is resumable, bounded, and proves the tag.** The full path —
|
|
113
|
+
feature PR, checks, merge, promotion, auto-merge, release run, tag — takes
|
|
114
|
+
longer than one call should block for, so each call advances as far as it can
|
|
115
|
+
and returns the stage it is parked at. Every stage is derived from live remote
|
|
116
|
+
state, never from a record of a previous call, so a resumed run and a fresh
|
|
117
|
+
one are the same code path. It reports `done: true` only after reading the tag
|
|
118
|
+
back from origin: a dispatched workflow, a merged PR and a green check are all
|
|
119
|
+
still "not done."
|
|
120
|
+
|
|
121
|
+
- **Version files can be `package.json`, `SKILL.md` frontmatter, `pyproject.toml`
|
|
122
|
+
or a top-level `project.yml`.** A component model that only reads
|
|
123
|
+
`package.json` is not generic, it is a node model — the first two non-node
|
|
124
|
+
repos this was pointed at were a Python project and an Xcode project. TOML and
|
|
125
|
+
YAML are matched at column zero only: both formats nest, and an indented
|
|
126
|
+
`version` is a dependency pin, not the project's own version. Releasing the
|
|
127
|
+
wrong number is worse than reporting none.
|
|
128
|
+
|
|
129
|
+
### Fixed
|
|
130
|
+
|
|
131
|
+
- **`spliceChangelog` built a regex out of the version string and escaped only
|
|
132
|
+
the dots**, leaving `\`, `*`, `+`, `(` and `[` live all the way into
|
|
133
|
+
`new RegExp`. `prepare` rejects a non-semver version before reaching it, so
|
|
134
|
+
this was not exploitable through the CLI — but the function is exported and
|
|
135
|
+
independently callable, and a guard that lives in the caller is not a guard.
|
|
136
|
+
It now matches with plain string operations, which is also exactly what
|
|
137
|
+
`_release.yml`'s `awk` does (`/^## / && index($0, ver)`), so the duplicate
|
|
138
|
+
check and the release-time extractor answer the same question the same way.
|
|
139
|
+
Found by CodeQL (`js/incomplete-sanitization`, high) on PR #158.
|
|
140
|
+
|
|
141
|
+
- **`dispatchReleaseWorkflow` ignored the `ownerRepo` it was given.** It shelled
|
|
142
|
+
out to `gh workflow run` with no `--repo`, so `gh` inferred the repository
|
|
143
|
+
from the process's working directory — which is routinely *not* the repo
|
|
144
|
+
`--repo <path>` points at. A dispatch into the wrong repository still exits 0,
|
|
145
|
+
so this failed silently in exactly the setup the flag exists for.
|
|
146
|
+
|
|
147
|
+
### Notes
|
|
148
|
+
|
|
149
|
+
- A breaking change on a component still in 0.x is capped at a **minor** bump,
|
|
150
|
+
and reported as capped rather than applied silently. Declaring 1.0.0 is an
|
|
151
|
+
API-stability promise, and no commit message is entitled to make it on the
|
|
152
|
+
maintainer's behalf.
|
|
153
|
+
- `release-prepare` does its work in a throwaway `git worktree`, so unrelated
|
|
154
|
+
uncommitted work in the user's tree cannot be swept into a release commit.
|
|
155
|
+
Real trees have parallel work in them; this monorepo's had an entire untracked
|
|
156
|
+
skill in it while this feature was written.
|
|
157
|
+
- No template changed, so no rendered workflow and no `renderedTemplateHashes`
|
|
158
|
+
entry moves in this release.
|
|
159
|
+
|
|
5
160
|
## 0.3.3 (2026-08-01) — least-privilege permissions in every rendered workflow
|
|
6
161
|
|
|
7
162
|
- **Every rendered workflow granted its permissions at the workflow level, so
|
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
|
|
|
@@ -63,7 +64,7 @@ user; the CLI is the only thing that *does*.
|
|
|
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
66
|
**If the candidate list is empty, a CI workflow has to exist before auto-merge
|
|
66
|
-
can be enabled. Hand that job to the `
|
|
67
|
+
can be enabled. Hand that job to the `ghfactory` skill** — authoring and *verifying*
|
|
67
68
|
workflow YAML is its whole subject, and it does things shipflow never will:
|
|
68
69
|
it resolves every action ref against the real API (no linter checks that an
|
|
69
70
|
action exists), validates each `with:` key against the action's own
|
|
@@ -71,10 +72,10 @@ user; the CLI is the only thing that *does*.
|
|
|
71
72
|
and zizmor before showing you anything. Two skills answering "scaffold me a CI
|
|
72
73
|
workflow" differently is worse than either answer.
|
|
73
74
|
|
|
74
|
-
> Use the
|
|
75
|
+
> Use the ghfactory skill to create a `pull_request`-triggered build+test workflow
|
|
75
76
|
> for this repo, then come back here with the job name.
|
|
76
77
|
|
|
77
|
-
**If
|
|
78
|
+
**If ghfactory is not installed**, draft it here instead: investigate the repo
|
|
78
79
|
directly (`package.json`, `Cargo.toml`, `project.yml`/`.xcodeproj`, `go.mod`,
|
|
79
80
|
`pyproject.toml`, or whatever's actually there) and write a minimal,
|
|
80
81
|
conservative `pull_request`-triggered build+test workflow.
|
|
@@ -140,6 +141,92 @@ This is a **separate, later invocation** from the one that ran the promotion's `
|
|
|
140
141
|
|
|
141
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.)
|
|
142
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
|
+
**`untagged-bump-on-main` is not, by itself, permission to cut.** Check `devAhead` first: if
|
|
168
|
+
it is set, dev already carries a *higher* version than what's on main, and cutting here would
|
|
169
|
+
tag the version on main, not the one on dev — the version you almost certainly mean to
|
|
170
|
+
release. `release-cut` refuses in this shape unless you pass `--version` naming exactly which
|
|
171
|
+
one to release (see step 3); it never guesses.
|
|
172
|
+
- `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.
|
|
173
|
+
- `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;
|
|
174
|
+
this means a tag was cut from something other than main, and guessing is how it gets worse.
|
|
175
|
+
|
|
176
|
+
2. **Show the user `collateral`, `blockers` and the proposed version, and wait.**
|
|
177
|
+
A `dev → main` promotion is atomic and carries all of dev, so every component listed under
|
|
178
|
+
`collateral` has its bump moved to `main` by the same promotion. It is **not released** by
|
|
179
|
+
that — every caller's release job is `workflow_dispatch`-only, so merging tags nothing; each
|
|
180
|
+
becomes `untagged-bump-on-main`, one deliberate `release-cut` away from a tag.
|
|
181
|
+
**Never run `release-cut` without naming that list to the user first.** They should know what
|
|
182
|
+
their promotion moves, and which components are now one dispatch from a release nobody asked
|
|
183
|
+
for.
|
|
184
|
+
|
|
185
|
+
`suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a
|
|
186
|
+
breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a
|
|
187
|
+
release decision, never a commit message's. Then:
|
|
188
|
+
```
|
|
189
|
+
npx -y @natjswenson/shipflow@latest release-prepare --repo <path> --component <name> \
|
|
190
|
+
--version <x.y.z> --notes-file <path>
|
|
191
|
+
```
|
|
192
|
+
Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work
|
|
193
|
+
in the user's tree is untouched and cannot be swept into the release commit. The version bump
|
|
194
|
+
and the CHANGELOG entry land in **one commit** — the notes are read off `main` at dispatch
|
|
195
|
+
time, so a CHANGELOG that lands in a later promotion than its version is notes the release
|
|
196
|
+
will never carry.
|
|
197
|
+
|
|
198
|
+
3. **Cut it, and prove it.**
|
|
199
|
+
```
|
|
200
|
+
npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
|
|
201
|
+
--expect-status-hash <hash-from-step-1> --wait 240
|
|
202
|
+
```
|
|
203
|
+
`--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
|
|
204
|
+
`--skip-hash-check` is a named escape hatch, never a default.
|
|
205
|
+
|
|
206
|
+
If step 1's `devAhead` was set, `release-cut` refuses outright with an error naming both
|
|
207
|
+
versions — this is the ambiguous three-way state (main has an untagged bump, dev already
|
|
208
|
+
carries something higher) where guessing would tag the wrong one. Promote `dev → main` and
|
|
209
|
+
re-run `release-status` to release what's on dev (the normal recovery), **or** add
|
|
210
|
+
`--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release
|
|
211
|
+
that one and leave dev's higher version for later. `--version` is a confirmation, not a
|
|
212
|
+
bypass — it is only ever accepted when it matches a version already on main or dev; anything
|
|
213
|
+
else is refused the same as passing nothing.
|
|
214
|
+
|
|
215
|
+
**`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full
|
|
216
|
+
path — feature PR, checks, merge, promotion, auto-merge, **dispatch**, release run, tag — takes
|
|
217
|
+
longer than one call should block for. Each call advances as far as it can, then returns the
|
|
218
|
+
`stage` it is parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It
|
|
219
|
+
derives every stage from live remote state and never from a record of what a previous call did,
|
|
220
|
+
so a resumed run and a fresh one are the same code path.
|
|
221
|
+
|
|
222
|
+
**The promotion merging cuts nothing.** `release-cut` dispatches the component's release
|
|
223
|
+
workflow itself, after the promotion lands — that dispatch is the single point at which any tag
|
|
224
|
+
is created in this repo, which is why a merge can no longer surprise anyone with a release.
|
|
225
|
+
|
|
226
|
+
4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
|
|
227
|
+
from origin. A dispatched workflow, a merged PR and a green check are **not** a release —
|
|
228
|
+
`release-cut` confirms the tag exists on the remote before it says done, and so must you.
|
|
229
|
+
|
|
143
230
|
## Auto mode (not yet implemented)
|
|
144
231
|
|
|
145
232
|
`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.
|
|
@@ -153,6 +240,15 @@ This is a **separate, later invocation** from the one that ran the promotion's `
|
|
|
153
240
|
- **`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.
|
|
154
241
|
- **`--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.
|
|
155
242
|
- **`--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.
|
|
243
|
+
- **`release-cut` returns `done: false`:** not an error. It is parked at the `stage` it reports,
|
|
244
|
+
waiting on something remote. Call it again with the same arguments. Do not report a release.
|
|
245
|
+
- **`release-status` reports `component-files-dirty`:** this component's own version files or
|
|
246
|
+
CHANGELOG have uncommitted edits, so a bump would collide with them. Unrelated dirt elsewhere in
|
|
247
|
+
the tree is reported under `notes` and is deliberately **not** a blocker — `prepare` runs in an
|
|
248
|
+
isolated worktree specifically so other people's in-flight work is safe.
|
|
249
|
+
- **`release-status` reports `version-unreadable-on-main`:** the component's version files do not
|
|
250
|
+
exist on main, or they disagree with each other. A disagreement is a hard refusal, never a
|
|
251
|
+
"pick the highest" — releasing from a disagreeing set tags one version and ships another.
|
|
156
252
|
- **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.
|
|
157
253
|
|
|
158
254
|
## 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,119 @@ 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
|
+
version: { type: 'string' },
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
const resolved = resolveReleaseArgs(values, 'release-cut');
|
|
343
|
+
if (resolved.error) return fail(resolved.error);
|
|
344
|
+
const ownerRepo = resolveOwnerRepo(values.repo);
|
|
345
|
+
if (!ownerRepo) return fail('release-cut: could not resolve owner/repo from git remote');
|
|
346
|
+
try {
|
|
347
|
+
const result = cut(values.repo, resolved.config, resolved.name, {
|
|
348
|
+
waitSeconds: values.wait ? Number(values.wait) : 240,
|
|
349
|
+
expectStatusHash: values['expect-status-hash'] ?? null,
|
|
350
|
+
skipHashCheck: values['skip-hash-check'],
|
|
351
|
+
ownerRepo,
|
|
352
|
+
version: values.version ?? null,
|
|
353
|
+
});
|
|
354
|
+
if (!result.ok) return fail(`release-cut: ${result.error}${result.currentStatusHash ? ` (current statusHash: ${result.currentStatusHash})` : ''}`);
|
|
355
|
+
printJson(result);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
return fail(`release-cut: ${e.message}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
247
361
|
function cmdRenameDefaultBranch(args) {
|
|
248
362
|
const { values } = parseArgs({
|
|
249
363
|
args,
|
|
@@ -274,6 +388,9 @@ Commands:
|
|
|
274
388
|
apply --repo <path> [--config <path>] [--dry-run] [--expect-state-hash <hash> | --skip-hash-check] [--force <id>]... [--force-reason <text>]
|
|
275
389
|
releases --repo <path> [--config <path>]
|
|
276
390
|
release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
|
|
391
|
+
release-status --repo <path> [--component <name>]
|
|
392
|
+
release-prepare --repo <path> [--component <name>] --version <x.y.z> --notes-file <path> [--date <YYYY-MM-DD>]
|
|
393
|
+
release-cut --repo <path> [--component <name>] (--expect-status-hash <hash> | --skip-hash-check) [--wait <seconds>] [--version <x.y.z>]
|
|
277
394
|
rename-default-branch --repo <path> --branch <current-name> --to <new-name>
|
|
278
395
|
|
|
279
396
|
Every command prints JSON to stdout.`);
|
|
@@ -314,6 +431,15 @@ if (isMain) {
|
|
|
314
431
|
case 'release-dispatch':
|
|
315
432
|
cmdReleaseDispatch(rest);
|
|
316
433
|
break;
|
|
434
|
+
case 'release-status':
|
|
435
|
+
cmdReleaseStatus(rest);
|
|
436
|
+
break;
|
|
437
|
+
case 'release-prepare':
|
|
438
|
+
cmdReleasePrepare(rest);
|
|
439
|
+
break;
|
|
440
|
+
case 'release-cut':
|
|
441
|
+
cmdReleaseCut(rest);
|
|
442
|
+
break;
|
|
317
443
|
case 'rename-default-branch':
|
|
318
444
|
cmdRenameDefaultBranch(rest);
|
|
319
445
|
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
|
|