@natjswenson/shipflow 0.4.0 → 0.7.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 +87 -0
- package/README.md +73 -6
- package/SKILL.md +111 -14
- package/bin/shipflow.js +4 -2
- package/lib/release.mjs +357 -64
- package/package.json +1 -1
- package/skill-invariants.json +13 -0
- package/templates/github-flow/main-automerge.yml.tmpl +31 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,93 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@natjswenson/shipflow` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.7.0 (2026-09-17)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added guarded main-only policy and GitHub Flow support.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Hardened draft/fork pull request handling and legacy release recovery.
|
|
14
|
+
|
|
15
|
+
## 0.6.0 (2026-08-03) — the ambiguous fast path is refused, not guessed
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **`release-cut`'s fast path could silently tag the OLDER version.** When a
|
|
20
|
+
component's `main` carried an untagged bump *and* `dev` independently carried
|
|
21
|
+
something higher — `lastTag < main < dev` — `readStatus` collapsed both facts
|
|
22
|
+
into the single `untagged-bump-on-main` state, and `cut()`'s fast path acted
|
|
23
|
+
on that state alone, dispatching a release for whatever sat on `main` while
|
|
24
|
+
the version actually being released sat, unread, on `dev`. Hit for real
|
|
25
|
+
during `/release eval` on 2026-08-03: `main` was at 0.2.1, `dev` at 0.3.0 —
|
|
26
|
+
`cut` would have tagged `eval-v0.2.1` and reported success. Caught only by
|
|
27
|
+
reading `release.mjs` before running the irreversible step.
|
|
28
|
+
|
|
29
|
+
`readStatus` now reports a `devAhead` fact (`{ version, aheadOfMain: true }`)
|
|
30
|
+
independently of `state` — including on a component's never-released first
|
|
31
|
+
bump, the sibling case a fix scoped only to the existing `state` branch would
|
|
32
|
+
have missed — plus a `dev-ahead-of-main` blocker, scoped to exactly the state
|
|
33
|
+
where the fast path is armed. A new pure `resolveReleaseTarget(status,
|
|
34
|
+
requestedVersion)` is the single place the release target is now decided;
|
|
35
|
+
`cut()` calls it once, before any network call, and refuses outright when
|
|
36
|
+
the target is ambiguous, naming both versions. There is no longer a code
|
|
37
|
+
path on which the tag `cut` waits for can disagree with the version it
|
|
38
|
+
decided to release.
|
|
39
|
+
|
|
40
|
+
The refusal is escapable, deliberately not inescapable: `release-cut` gains
|
|
41
|
+
`--version <x.y.z>`, a *confirmation* rather than a bypass — it is only ever
|
|
42
|
+
accepted when it names a version already present on `main` or `dev` in that
|
|
43
|
+
status, so there is no value of it that releases a version which isn't
|
|
44
|
+
actually on the branch being dispatched.
|
|
45
|
+
|
|
46
|
+
## 0.5.0 (2026-08-02) — the merge stops cutting tags; the dispatch is the release
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **`release-cut` now dispatches the component's release workflow itself, after
|
|
51
|
+
the promotion lands.** Previously it merged the promotion and then *waited*
|
|
52
|
+
for a tag that a `push`-triggered job happened to cut. That made a merge the
|
|
53
|
+
real release trigger, which meant any promotion released everything bumped on
|
|
54
|
+
`dev` — whether or not anyone asked, and irreversibly for skills that publish
|
|
55
|
+
to npm.
|
|
56
|
+
|
|
57
|
+
Paired with every caller's `release` job becoming `workflow_dispatch`-only,
|
|
58
|
+
this makes the dispatch the **single point at which a tag is ever created**.
|
|
59
|
+
A `dev → main` merge now moves a version bump to `main` and stops there; the
|
|
60
|
+
component simply becomes `untagged-bump-on-main` until someone releases it on
|
|
61
|
+
purpose.
|
|
62
|
+
|
|
63
|
+
The two halves are load-bearing together. Removing the `push` gate without
|
|
64
|
+
this dispatch leaves `release-cut` waiting forever for a tag nobody cuts;
|
|
65
|
+
adding the dispatch without removing the gate double-releases.
|
|
66
|
+
|
|
67
|
+
- **`collateral` means something smaller and safer now.** It still lists every
|
|
68
|
+
other component whose bump the same promotion moves to `main` — that is
|
|
69
|
+
unavoidable, a promotion is atomic — but those components are no longer
|
|
70
|
+
*released* by it. The disclosure stays because the user should know what their
|
|
71
|
+
promotion moves, and which components are now one dispatch from a release
|
|
72
|
+
nobody asked for.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- **`release-status` returned a wrong commit list in a shallow clone, silently.**
|
|
77
|
+
`git log <tag>..<ref>` excludes everything reachable from `<tag>`, and that
|
|
78
|
+
exclusion needs full ancestry. In a grafted history it under-applies, so the
|
|
79
|
+
range returns commits that shipped long ago — without erroring, and therefore
|
|
80
|
+
with a `suggestedBump` derived from fiction.
|
|
81
|
+
|
|
82
|
+
Observed on this repo the day 0.4.0 shipped: a depth-1 checkout of `main`
|
|
83
|
+
reported **1 unreleased commit** for a component a full clone correctly
|
|
84
|
+
reported as **0**, which would have proposed a patch release for nothing.
|
|
85
|
+
`actions/checkout` is depth-1 by default, so any CI job calling `release-status`
|
|
86
|
+
hit this.
|
|
87
|
+
|
|
88
|
+
A shallow repository is now a **blocker**, not a note — every number derived
|
|
89
|
+
from the commit range is untrustworthy, so the honest answer is to refuse and
|
|
90
|
+
say `git fetch --unshallow`, rather than to report a plausible wrong one.
|
|
91
|
+
|
|
5
92
|
## 0.4.0 (2026-08-02) — release one named thing, and prove the tag exists
|
|
6
93
|
|
|
7
94
|
### Added
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# shipflow
|
|
2
2
|
|
|
3
|
-
<!-- >>> press:masthead v0.
|
|
4
|
-
**NS** · NATE SWENSON · CLAUDE CODE SKILL · PRESS v0.
|
|
3
|
+
<!-- >>> press:masthead v0.11.0 sha256:582134ecd7a5 GENERATED by @natjswenson/press, do not edit -->
|
|
4
|
+
**NS** · NATE SWENSON · CLAUDE CODE + CODEX SKILL · PRESS v0.11.0 · linkedin.com/in/natejswenson
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
<!-- <<< press:masthead -->
|
|
@@ -34,11 +34,32 @@ three against the repo's real shape — it never silently picks one.
|
|
|
34
34
|
|---|---|
|
|
35
35
|
| `skills/shipflow/SKILL.md` | The interactive setup interview, and where it must stop and ask. |
|
|
36
36
|
| `skills/shipflow/bin/` | The CLI: `detect`, `plan`, `apply`, `releases`, `release-dispatch`. |
|
|
37
|
-
| `skills/shipflow/templates/` |
|
|
37
|
+
| `skills/shipflow/templates/` | GitHub Flow auto-merge for ready same-repository PRs, including `ready_for_review`; drafts and forks skip. |
|
|
38
38
|
| `skills/shipflow/skill-invariants.json` | The prose guardrails and the baseline eval declaration. |
|
|
39
39
|
|
|
40
40
|
## Quick start
|
|
41
41
|
|
|
42
|
+
Claude Code — run in chat:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
/plugin marketplace add natejswenson/claude-skills
|
|
46
|
+
/plugin install shipflow@claude-skills
|
|
47
|
+
/shipflow
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Codex — run in a terminal from the root of this repository checkout:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
codex plugin marketplace add "$PWD"
|
|
54
|
+
codex plugin add shipflow@claude-skills
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Start a new Codex session, then invoke in chat:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
$shipflow
|
|
61
|
+
```
|
|
62
|
+
|
|
42
63
|
```sh
|
|
43
64
|
npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
|
|
44
65
|
```
|
|
@@ -60,6 +81,13 @@ npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
|
|
|
60
81
|
|
|
61
82
|
## Requirements
|
|
62
83
|
|
|
84
|
+
- **Claude Code:** Expose the authenticated `gh` CLI to shell tools.
|
|
85
|
+
- **Codex:** Use the same `gh` authentication and repository permissions; Claude app connections are not imported.
|
|
86
|
+
- **GitHub Flow credentials:** A configured PAT/App repository secret is required; an unavailable token skips cleanly. Maintainers handle fork merges and optional reminders with their own credentials.
|
|
87
|
+
- **Personal data:** Configuration stays in `.github/shipflow.json` in the target repository; no private `~/.claude/shipflow` store is required.
|
|
88
|
+
|
|
89
|
+
See [Codex migration notes](../../docs/codex-migration.md) for host tools and retained data paths.
|
|
90
|
+
|
|
63
91
|
- Node 18+.
|
|
64
92
|
- [`gh`](https://cli.github.com/), authenticated with admin rights on the target
|
|
65
93
|
repo — branch protection cannot be read or written without them.
|
|
@@ -72,7 +100,7 @@ npx -y @natjswenson/shipflow@latest detect --repo . --main main --dev dev
|
|
|
72
100
|
| Pattern | Shape |
|
|
73
101
|
|---|---|
|
|
74
102
|
| `dev-main-promotion` | Long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main` |
|
|
75
|
-
| `github-flow` | Single long-lived `main`;
|
|
103
|
+
| `github-flow` | Single long-lived `main`; ready same-repository PRs can auto-merge directly to it |
|
|
76
104
|
| `gitflow` | `develop` + `main` + transient `release/*`/`hotfix/*`, for software maintaining multiple released versions concurrently |
|
|
77
105
|
|
|
78
106
|
`detect` scores all three against the repo's branches, tags and workflow files,
|
|
@@ -90,10 +118,42 @@ detection is ambiguous or the repo is greenfield.
|
|
|
90
118
|
3. **`shipflow apply`** — only after you confirm — renders the resolved pattern's
|
|
91
119
|
workflow files and makes the confirmed mutations. Nothing happens outside what
|
|
92
120
|
the plan showed.
|
|
93
|
-
4. Ongoing:
|
|
121
|
+
4. Ongoing: eligible PRs auto-merge once required checks pass; a durable
|
|
94
122
|
`release-pending` label survives the async gap until a later
|
|
95
123
|
`shipflow releases` check asks whether to cut a release.
|
|
96
124
|
|
|
125
|
+
## GitHub Flow contributions
|
|
126
|
+
|
|
127
|
+
Draft PRs skip merge automation. Ready PRs from the same repository can enable
|
|
128
|
+
native auto-merge on `opened`, `reopened`, `synchronize` or `ready_for_review`;
|
|
129
|
+
GitHub's required checks gate the merge. Keep implementation PRs draft until review
|
|
130
|
+
and the authorized merge decision are complete.
|
|
131
|
+
|
|
132
|
+
Repository write access is the trust boundary. Forks and missing head repositories
|
|
133
|
+
skip both jobs. Maintainers review fork PRs and explicitly enable auto-merge or
|
|
134
|
+
merge with their own authorized credentials after required checks. The workflow
|
|
135
|
+
uses `pull_request` and executes no PR code.
|
|
136
|
+
|
|
137
|
+
Configure `release.releaseCredential` as the name of a PAT/App repository secret
|
|
138
|
+
with `contents: write` and `pull-requests: write`. Both commands skip cleanly if that
|
|
139
|
+
named secret is unavailable, without falling back to `GITHUB_TOKEN`; setup must
|
|
140
|
+
still provision it. Omitting the credential name instead retains the renderer’s
|
|
141
|
+
legacy `GITHUB_TOKEN` default, which does not guarantee a skip or the merged-PR
|
|
142
|
+
reminder. Always configure and provision the named PAT/App secret for this flow.
|
|
143
|
+
A merged same-repository PR receives an optional `release-pending` label;
|
|
144
|
+
unmerged closes and fork merges skip it. Maintainers may label fork merges manually,
|
|
145
|
+
and component `release-status` discovers untagged work without labels. No PR event
|
|
146
|
+
creates a tag or release.
|
|
147
|
+
|
|
148
|
+
To migrate an existing generated workflow, run plan/apply with the corrected engine.
|
|
149
|
+
During unreleased development, invoke `node <skill-directory>/bin/shipflow.js` from
|
|
150
|
+
the corrected checkout; after release use `npx -y @natjswenson/shipflow@latest`.
|
|
151
|
+
Review the plan, including any live settings changes, and apply with its state hash.
|
|
152
|
+
An existing workflow matching its old `renderedTemplateHashes` receipt is recognized
|
|
153
|
+
as a generator update. Commit the generated YAML and returned receipt together,
|
|
154
|
+
then replan to confirm no template drift. Genuine hand edits still block apply;
|
|
155
|
+
never edit the hash to hide them.
|
|
156
|
+
|
|
97
157
|
## Commands
|
|
98
158
|
|
|
99
159
|
| Command | What it does |
|
|
@@ -101,7 +161,7 @@ detection is ambiguous or the repo is greenfield.
|
|
|
101
161
|
| `detect --repo <path> [--main <name>] [--dev <name>]` | Inspect live repo state: branch protection, CI checks, release conventions |
|
|
102
162
|
| `plan --repo <path>` | Diff `.github/shipflow.json` against live state; prints what would change plus a state hash |
|
|
103
163
|
| `apply --repo <path> --expect-state-hash <hash> [--dry-run] [--force <id> --force-reason <text>]` | Apply a confirmed plan |
|
|
104
|
-
| `releases --repo <path>` | List
|
|
164
|
+
| `releases --repo <path>` | List merged main PR reminders (`mergedPrs` for GitHub flow, `promotions` for legacy consumers) |
|
|
105
165
|
| `release-dispatch --repo <path> --pr <n> --workflow-file <f>... --ref <ref>` | Dispatch each changed skill's release workflow; clear the label on success |
|
|
106
166
|
| `rename-default-branch --repo <path> --branch <old> --to <new>` | One-time bootstrap: rename a repo's default branch |
|
|
107
167
|
|
|
@@ -145,3 +205,10 @@ See [`CHANGELOG.md`](CHANGELOG.md). Releases are cut by a version bump, tagged
|
|
|
145
205
|
## License
|
|
146
206
|
|
|
147
207
|
MIT — see [`LICENSE`](LICENSE).
|
|
208
|
+
|
|
209
|
+
Component releases honor the selected branch pattern. GitHub flow prepares and
|
|
210
|
+
merges its version/changelog PR directly into configured main; two-branch repos
|
|
211
|
+
retain promotion. `release-status` discovers pending components without labels.
|
|
212
|
+
`release-cut --version <x.y.z>` checks required CI, verifies the intended version
|
|
213
|
+
and notes on fetched main, explicitly dispatches one workflow, and proves the
|
|
214
|
+
remote tag. A merge or push alone does not release a component.
|
package/SKILL.md
CHANGED
|
@@ -2,10 +2,28 @@
|
|
|
2
2
|
name: shipflow
|
|
3
3
|
description: Scaffold a configurable dev/main branching, auto-merge, branch-cleanup, and release-tagging workflow into any repo. Detects existing branch protection, CI checks, and release conventions; always shows a plan and waits for confirmation before mutating anything. Use when the user asks to set up branch protection standards, apply deployment/release standards to a repo, or wants long-lived dev/main branches with auto-merge and branch cleanup.
|
|
4
4
|
user_invocable: true
|
|
5
|
+
version: 0.7.0
|
|
5
6
|
---
|
|
6
7
|
|
|
8
|
+
## Codex runtime
|
|
9
|
+
|
|
10
|
+
When running in Codex, invoke this skill as `$shipflow`. Resolve scripts, assets,
|
|
11
|
+
and references from the directory containing this SKILL.md, regardless of the
|
|
12
|
+
current working directory. Existing `~/.claude/` personal-data paths remain valid
|
|
13
|
+
and are still used by the bundled scripts; they do not require Claude to run.
|
|
14
|
+
Map `Read`/`Write`/`Edit`/`Bash` to the available file and shell tools, and
|
|
15
|
+
`WebSearch`/`WebFetch` to available web tools. For `AskUserQuestion`, use an
|
|
16
|
+
available question tool or a concise chat question; wait for answers that gate
|
|
17
|
+
action. Use Codex's delegation tools for required subagents when available;
|
|
18
|
+
otherwise disclose that independent execution is unavailable. Discover connected
|
|
19
|
+
apps by capability rather than assuming Claude MCP tool names exist.
|
|
20
|
+
|
|
7
21
|
# /shipflow — branching + release-automation setup
|
|
8
22
|
|
|
23
|
+
<!-- press:runtime -->
|
|
24
|
+
In Claude Code, load `/press`; in Codex, load `$press`; then follow the shared PRESS terminal/UI contract from `brand/agent-ui.md`. Do not copy or override that contract here.
|
|
25
|
+
<!-- press:runtime -->
|
|
26
|
+
|
|
9
27
|
All deterministic work is delegated to the CLI. Invoke it as
|
|
10
28
|
`npx -y @natjswenson/shipflow@latest <command>` — **always with the explicit
|
|
11
29
|
`@latest` tag, never bare `@natjswenson/shipflow`.** Without a version/tag,
|
|
@@ -48,7 +66,7 @@ user; the CLI is the only thing that *does*.
|
|
|
48
66
|
- **Confident:** state what was detected and why (the top entry's `evidence` array) — *"I detected this repo is using **`<pattern-id>`** because: `<evidence bullets>`. I'll set `workflowPattern` to this — confirm before I proceed, or tell me if you'd rather pick a different pattern."* This is still a confirm-before-write checkpoint per this section's mandatory-interview rule — a confident autodetect is not a substitute for the user's explicit confirmation.
|
|
49
67
|
- **Ambiguous or greenfield:** present all 3 patterns and ask the user to choose. Do not silently pick one:
|
|
50
68
|
- `dev-main-promotion` — long-lived `dev` + `main`; a promotion PR auto-merges `dev` into `main`.
|
|
51
|
-
- `github-flow` — single long-lived `main`;
|
|
69
|
+
- `github-flow` — single long-lived `main`; ready same-repository PRs can auto-merge directly to `main`; forks require maintainer action. Suggest this as the lightweight default for a **greenfield** repo specifically, without auto-picking it.
|
|
52
70
|
- `gitflow` — `develop` + `main` + transient `release/*`/`hotfix/*` branches, for software that maintains multiple released versions concurrently.
|
|
53
71
|
- Once resolved, proceed with only the interview fields that pattern's config actually uses — skip asking about a `dev` branch name under `github-flow`, for instance.
|
|
54
72
|
- If `workflowPattern` is `gitflow`, additionally ask for `releaseBranchPrefix`/`hotfixBranchPrefix` (defaulting to `release/`/`hotfix/` if the user has no preference) — recorded under `patternConfig.gitflow` in the config.
|
|
@@ -121,6 +139,45 @@ user; the CLI is the only thing that *does*.
|
|
|
121
139
|
|
|
122
140
|
Same as steps 1, 8, 9, 10, 11 above, skipping the interview (`workflowPattern`/branch names/checks/protectionOwner/releaseCredential are already recorded in `.github/shipflow.json` — read it, don't re-ask, unless the user explicitly says they want to reconfigure). Step 2's pattern resolution never runs on a re-run — `workflowPattern`'s absence from a config genuinely means "not yet resolved," and its presence means "already resolved," so there's nothing to detect again. If `plan.creates`/`plan.updates` is non-empty, that's drift since the last apply — show it and confirm before applying, exactly as in first-run setup.
|
|
123
141
|
|
|
142
|
+
## GitHub Flow readiness, forks and migration
|
|
143
|
+
|
|
144
|
+
For `workflowPattern: "github-flow"`, the generated `pull_request` workflow handles
|
|
145
|
+
`opened`, `reopened`, `synchronize`, `ready_for_review` and `closed` on configured
|
|
146
|
+
main. Only a non-draft PR whose head repository matches the target repository can
|
|
147
|
+
enable native auto-merge. Drafts skip cleanly; making a reviewed draft ready has
|
|
148
|
+
its own trigger. Native GitHub required checks still gate the merge. Keep an
|
|
149
|
+
implementation PR draft until review and the authorized merge decision are complete.
|
|
150
|
+
|
|
151
|
+
Repository write access is the trust boundary. Forks and missing head repositories
|
|
152
|
+
skip both jobs, regardless of contributor association or labels. Maintainers must
|
|
153
|
+
review fork PRs and explicitly enable native auto-merge or merge with their own
|
|
154
|
+
authorized credentials after required checks. Do not switch to
|
|
155
|
+
`pull_request_target`, check out PR code, or run contributor-controlled scripts to
|
|
156
|
+
make credentials available.
|
|
157
|
+
|
|
158
|
+
Both commands use the configured `release.releaseCredential` PAT/App secret from
|
|
159
|
+
setup step 6. If that named secret is unavailable or unset, the step reports a clean
|
|
160
|
+
skip and invokes no `gh` command; it does not fall back to `GITHUB_TOKEN`. This does
|
|
161
|
+
not provision or validate the credential. Omitting `release.releaseCredential`
|
|
162
|
+
from configuration instead retains the renderer’s legacy `GITHUB_TOKEN` default,
|
|
163
|
+
which does not guarantee a skip or the merged-PR reminder. Always configure and
|
|
164
|
+
provision the named PAT/App secret for this flow.
|
|
165
|
+
|
|
166
|
+
Only a merged same-repository PR receives the optional `release-pending` reminder.
|
|
167
|
+
Unmerged closes and fork merges skip it; a maintainer may label a fork merge
|
|
168
|
+
manually. Component `release-status` discovers untagged work without labels. No
|
|
169
|
+
PR event cuts a tag or release.
|
|
170
|
+
|
|
171
|
+
When upgrading this template, use the corrected checkout's
|
|
172
|
+
`node <skill-directory>/bin/shipflow.js plan --repo <target>` and matching `apply`
|
|
173
|
+
during unreleased development; use the explicit `@latest` invocation after release.
|
|
174
|
+
Follow the reviewed plan and state-hash apply process above, including any live
|
|
175
|
+
settings changes it proposes. If the existing workflow matches its recorded old
|
|
176
|
+
`renderedTemplateHashes` receipt, plan recognizes a template update. Apply generates
|
|
177
|
+
new bytes and returns the new receipt; commit both together and replan to verify
|
|
178
|
+
no template drift. A genuine hand edit still requires the explicit scoped override;
|
|
179
|
+
never manufacture a matching receipt by hand to bypass that refusal.
|
|
180
|
+
|
|
124
181
|
## Check pending releases (`manual-gate` ask-flow)
|
|
125
182
|
|
|
126
183
|
This is a **separate, later invocation** from the one that ran the promotion's `apply` — native GitHub auto-merge completes asynchronously, with no live session attached at the moment of the actual merge. A durable `release-pending` label is what survives that gap.
|
|
@@ -154,25 +211,51 @@ and release workflow live, with `{name}` as the only substitution token;
|
|
|
154
211
|
its root (`package.json`, `CHANGELOG.md`, `v{version}`), so a one-project repo needs no config
|
|
155
212
|
at all and `--component` may be omitted.
|
|
156
213
|
|
|
214
|
+
`workflowPattern: "github-flow"` is authoritative: preparation and feature PRs
|
|
215
|
+
use configured main, status never reads dev, and cut skips promotion. A stale dev
|
|
216
|
+
field does not change that. Absent-pattern and two-branch consumers retain the
|
|
217
|
+
integration branch, promotion and dev-ahead refusal. GitHub-flow `releases`
|
|
218
|
+
returns `mergedPrs`; legacy consumers retain `promotions`. These labels are
|
|
219
|
+
optional reminders: component `release-status` inventories pending versions and
|
|
220
|
+
tags without them, and `release-cut` requires no label.
|
|
221
|
+
|
|
222
|
+
For the declared `skills/{name}/.codex-plugin/plugin.json` layout, preparation
|
|
223
|
+
requires this repository's `tools/sync_codex.py` and `tools/check_compatibility.py`.
|
|
224
|
+
It checks existing metadata, updates both lockfile version fields and the shared
|
|
225
|
+
version files, runs generation and compatibility checks in the worktree, and
|
|
226
|
+
refuses unrelated generated edits. Generic consumers have no Python dependency.
|
|
227
|
+
Before requesting merge, cut reads the full live required-check set; before
|
|
228
|
+
explicit dispatch, it fetches and verifies main's intended version and changelog.
|
|
229
|
+
Keep main PRs draft until their review and authorized merge decision are complete;
|
|
230
|
+
the existing GitHub-flow workflow enables native auto-merge on eligible PRs.
|
|
231
|
+
|
|
157
232
|
1. **Read the state. Never guess it.**
|
|
158
233
|
```
|
|
159
234
|
npx -y @natjswenson/shipflow@latest release-status --repo <path> --component <name>
|
|
160
235
|
```
|
|
161
|
-
Returns `state`, the version on main and dev, the last tag, every commit since that tag that
|
|
236
|
+
Returns `state`, `workflowPattern`, `releaseBase`, the version on main (and dev for two-branch repos), the last tag, every commit since that tag that
|
|
162
237
|
touched this component's paths, a `suggestedBump` with its reason, `blockers`, `notes`, and a
|
|
163
238
|
`statusHash`. `state` decides the path:
|
|
164
239
|
- `clean` — the released version is what's on main. A bump is needed: go to step 2.
|
|
165
240
|
- `untagged-bump-on-main` — the bump is already on main and was never tagged (a cancelled or
|
|
166
241
|
failed release run). **No PR is needed** — `release-cut` dispatches and verifies. Skip to step 3.
|
|
242
|
+
**`untagged-bump-on-main` is not, by itself, permission to cut.** Check `devAhead` first: if
|
|
243
|
+
it is set, dev already carries a *higher* version than what's on main, and cutting here would
|
|
244
|
+
tag the version on main, not the one on dev — the version you almost certainly mean to
|
|
245
|
+
release. `release-cut` refuses in this shape unless you pass `--version` naming exactly which
|
|
246
|
+
one to release (see step 3); it never guesses.
|
|
167
247
|
- `bump-on-dev-unpromoted` — the bump is on dev, waiting for a promotion. Skip to step 3.
|
|
168
248
|
- `version-behind-tag` — main carries a *lower* version than an existing tag. Stop and ask;
|
|
169
249
|
this means a tag was cut from something other than main, and guessing is how it gets worse.
|
|
170
250
|
|
|
171
251
|
2. **Show the user `collateral`, `blockers` and the proposed version, and wait.**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
252
|
+
A `dev → main` promotion is atomic and carries all of dev, so every component listed under
|
|
253
|
+
`collateral` has its bump moved to `main` by the same promotion. It is **not released** by
|
|
254
|
+
that — every caller's release job is `workflow_dispatch`-only, so merging tags nothing; each
|
|
255
|
+
becomes `untagged-bump-on-main`, one deliberate `release-cut` away from a tag.
|
|
256
|
+
**Never run `release-cut` without naming that list to the user first.** They should know what
|
|
257
|
+
their promotion moves, and which components are now one dispatch from a release nobody asked
|
|
258
|
+
for.
|
|
176
259
|
|
|
177
260
|
`suggestedBump` is a suggestion. The user decides, and a `suggestedBumpCapped: true` means a
|
|
178
261
|
breaking change was held at minor because the component is still 0.x — going to 1.0.0 is a
|
|
@@ -183,23 +266,37 @@ at all and `--component` may be omitted.
|
|
|
183
266
|
```
|
|
184
267
|
Local only, no network. It works in a **throwaway git worktree**, so unrelated uncommitted work
|
|
185
268
|
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
|
|
187
|
-
|
|
269
|
+
and the CHANGELOG entry land in **one commit** — the notes are read off `main` at dispatch
|
|
270
|
+
time, so a CHANGELOG that lands in a later promotion than its version is notes the release
|
|
271
|
+
will never carry.
|
|
188
272
|
|
|
189
273
|
3. **Cut it, and prove it.**
|
|
190
274
|
```
|
|
191
275
|
npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
|
|
192
|
-
--expect-status-hash <hash-from-step-1> --wait 240
|
|
276
|
+
--version <prepared-or-confirmed-version> --expect-status-hash <hash-from-step-1> --wait 240
|
|
193
277
|
```
|
|
194
278
|
`--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
|
|
195
279
|
`--skip-hash-check` is a named escape hatch, never a default.
|
|
196
280
|
|
|
281
|
+
If step 1's `devAhead` was set, `release-cut` refuses outright with an error naming both
|
|
282
|
+
versions — this is the ambiguous three-way state (main has an untagged bump, dev already
|
|
283
|
+
carries something higher) where guessing would tag the wrong one. Promote `dev → main` and
|
|
284
|
+
re-run `release-status` to release what's on dev (the normal recovery), **or** add
|
|
285
|
+
`--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release
|
|
286
|
+
that one and leave dev's higher version for later. `--version` is a confirmation, not a
|
|
287
|
+
bypass — it is only ever accepted when it matches a version already on main, dev, or the verified prepared branch; anything
|
|
288
|
+
else is refused the same as passing nothing.
|
|
289
|
+
|
|
197
290
|
**`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
|
|
199
|
-
one call should block for. Each call advances as far as it can, then returns the
|
|
200
|
-
parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It
|
|
201
|
-
stage from live remote state and never from a record of what a previous call did,
|
|
202
|
-
run and a fresh one are the same code path.
|
|
291
|
+
path — feature PR, checks, merge, promotion, auto-merge, **dispatch**, release run, tag — takes
|
|
292
|
+
longer than one call should block for. Each call advances as far as it can, then returns the
|
|
293
|
+
`stage` it is parked at and a `next` line. **Call it again, unchanged, until `done: true`.** It
|
|
294
|
+
derives every stage from live remote state and never from a record of what a previous call did,
|
|
295
|
+
so a resumed run and a fresh one are the same code path.
|
|
296
|
+
|
|
297
|
+
**Merging cuts nothing.** `release-cut` dispatches the component's release
|
|
298
|
+
workflow itself, after the selected version and notes reach main — that dispatch is the single point at which any tag
|
|
299
|
+
is created in this repo, which is why a merge can no longer surprise anyone with a release.
|
|
203
300
|
|
|
204
301
|
4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
|
|
205
302
|
from origin. A dispatched workflow, a merged PR and a green check are **not** a release —
|
package/bin/shipflow.js
CHANGED
|
@@ -211,7 +211,7 @@ function cmdReleases(args) {
|
|
|
211
211
|
...p,
|
|
212
212
|
...confirmPromotionMerged(ownerRepo, p.number),
|
|
213
213
|
}));
|
|
214
|
-
printJson({ promotions: withMergeCheck });
|
|
214
|
+
printJson(config.workflowPattern === 'github-flow' ? { mergedPrs: withMergeCheck } : { promotions: withMergeCheck });
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
function cmdReleaseDispatch(args) {
|
|
@@ -336,6 +336,7 @@ function cmdReleaseCut(args) {
|
|
|
336
336
|
'expect-status-hash': { type: 'string' },
|
|
337
337
|
'skip-hash-check': { type: 'boolean', default: false },
|
|
338
338
|
wait: { type: 'string' },
|
|
339
|
+
version: { type: 'string' },
|
|
339
340
|
},
|
|
340
341
|
});
|
|
341
342
|
const resolved = resolveReleaseArgs(values, 'release-cut');
|
|
@@ -348,6 +349,7 @@ function cmdReleaseCut(args) {
|
|
|
348
349
|
expectStatusHash: values['expect-status-hash'] ?? null,
|
|
349
350
|
skipHashCheck: values['skip-hash-check'],
|
|
350
351
|
ownerRepo,
|
|
352
|
+
version: values.version ?? null,
|
|
351
353
|
});
|
|
352
354
|
if (!result.ok) return fail(`release-cut: ${result.error}${result.currentStatusHash ? ` (current statusHash: ${result.currentStatusHash})` : ''}`);
|
|
353
355
|
printJson(result);
|
|
@@ -388,7 +390,7 @@ Commands:
|
|
|
388
390
|
release-dispatch --repo <path> --pr <number> --workflow-file <file>... --ref <ref>
|
|
389
391
|
release-status --repo <path> [--component <name>]
|
|
390
392
|
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>]
|
|
393
|
+
release-cut --repo <path> [--component <name>] (--expect-status-hash <hash> | --skip-hash-check) [--wait <seconds>] [--version <x.y.z>]
|
|
392
394
|
rename-default-branch --repo <path> --branch <current-name> --to <new-name>
|
|
393
395
|
|
|
394
396
|
Every command prints JSON to stdout.`);
|
package/lib/release.mjs
CHANGED
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
// prepare() — local writes only, in a THROWAWAY WORKTREE (see below)
|
|
13
13
|
// cut() — the only irreversible one, gated on a status hash
|
|
14
14
|
//
|
|
15
|
-
// Why a throwaway worktree: `prepare`
|
|
15
|
+
// Why a throwaway worktree: `prepare` branches from the configured base and commits, and a
|
|
16
16
|
// real repo's working tree routinely has unrelated in-flight work in it (this
|
|
17
17
|
// monorepo's own tree did while this was written). Checking out a branch under
|
|
18
18
|
// that, or staging from it, is how another session's uncommitted work gets
|
|
19
19
|
// swept into a release commit. A `git worktree` is a clean, isolated checkout
|
|
20
|
-
// of
|
|
20
|
+
// of that base that cannot see the user's dirt at all, so there is nothing to sweep.
|
|
21
21
|
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
22
23
|
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
23
24
|
import { join, resolve, sep } from 'node:path';
|
|
24
25
|
import { tmpdir } from 'node:os';
|
|
@@ -332,16 +333,34 @@ function revParse(repoPath, ref) {
|
|
|
332
333
|
}
|
|
333
334
|
|
|
334
335
|
function dirtyPaths(repoPath, relPaths) {
|
|
335
|
-
const r = git
|
|
336
|
+
const r = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...relPaths], {
|
|
337
|
+
cwd: repoPath, encoding: 'utf8', timeout: 30_000,
|
|
338
|
+
});
|
|
336
339
|
if (r.status !== 0 || !r.stdout) return [];
|
|
337
|
-
|
|
340
|
+
const records = r.stdout.split('\0');
|
|
341
|
+
const paths = [];
|
|
342
|
+
for (let i = 0; i < records.length; i++) {
|
|
343
|
+
const record = records[i];
|
|
344
|
+
if (!record) continue;
|
|
345
|
+
paths.push(record.slice(3));
|
|
346
|
+
if (/[RC]/.test(record.slice(0, 2))) i++; // rename/copy source follows destination
|
|
347
|
+
}
|
|
348
|
+
return paths;
|
|
338
349
|
}
|
|
339
350
|
|
|
340
|
-
// Every OTHER component whose version at `dev` carries no tag.
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
351
|
+
// Every OTHER component whose version at `dev` carries no tag. A promotion is
|
|
352
|
+
// atomic and carries all of dev, so these components' bumps land on `main`
|
|
353
|
+
// alongside the one being released, whether or not anyone asked.
|
|
354
|
+
//
|
|
355
|
+
// They are NOT released by that. Since the release jobs became
|
|
356
|
+
// workflow_dispatch-only, landing on main tags nothing — each of these simply
|
|
357
|
+
// becomes `untagged-bump-on-main`, releasable later by an explicit `release-cut`.
|
|
358
|
+
// That is a far safer default than the old behaviour, where the same promotion
|
|
359
|
+
// tagged and npm-published every one of them within seconds of merging.
|
|
360
|
+
//
|
|
361
|
+
// It is still worth saying out loud: the user should know what their promotion
|
|
362
|
+
// is moving to main, and which components are now sitting one dispatch away
|
|
363
|
+
// from a release they did not ask for.
|
|
345
364
|
export function collateralComponents(repoPath, config, exceptName, devRef) {
|
|
346
365
|
const out = [];
|
|
347
366
|
for (const name of listComponentNames(config, repoPath)) {
|
|
@@ -361,30 +380,53 @@ export function collateralComponents(repoPath, config, exceptName, devRef) {
|
|
|
361
380
|
return out;
|
|
362
381
|
}
|
|
363
382
|
|
|
383
|
+
// An explicit pattern is authoritative even during a migration with stale dev fields.
|
|
384
|
+
function branchPolicy(config) {
|
|
385
|
+
const workflowPattern = config?.workflowPattern ?? 'dev-main-promotion';
|
|
386
|
+
const main = config?.branches?.main ?? 'main';
|
|
387
|
+
const dev = workflowPattern === 'github-flow' ? null : (config?.branches?.dev ?? 'dev');
|
|
388
|
+
return { workflowPattern, main, dev, base: dev ?? main };
|
|
389
|
+
}
|
|
390
|
+
|
|
364
391
|
export function readStatus(repoPath, config, name) {
|
|
365
392
|
const component = resolveComponent(repoPath, config, name);
|
|
366
|
-
const
|
|
367
|
-
const
|
|
393
|
+
const policy = branchPolicy(config);
|
|
394
|
+
const { main: mainBranch, dev: devBranch } = policy;
|
|
368
395
|
|
|
369
396
|
// Read from the REMOTE-tracking refs, not the local branches: a local `main`
|
|
370
397
|
// that has not been fetched in a week would compute a bump against a stale
|
|
371
398
|
// baseline and silently propose a version that is already tagged.
|
|
372
399
|
const fetched = git(['fetch', 'origin', '--tags', '--prune'], { cwd: repoPath });
|
|
373
400
|
const mainRef = revParse(repoPath, `origin/${mainBranch}`) ? `origin/${mainBranch}` : mainBranch;
|
|
374
|
-
const devRef = revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch;
|
|
401
|
+
const devRef = devBranch ? (revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch) : null;
|
|
375
402
|
|
|
376
403
|
const onMain = readVersionAt(repoPath, component, mainRef);
|
|
377
|
-
const onDev = readVersionAt(repoPath, component, devRef);
|
|
404
|
+
const onDev = devRef ? readVersionAt(repoPath, component, devRef) : { ok: false, version: null };
|
|
378
405
|
const lastVersion = latestVersionTagged(repoPath, component);
|
|
379
406
|
const lastTag = lastVersion ? tagFor(component, lastVersion) : null;
|
|
380
407
|
|
|
381
408
|
const blockers = [];
|
|
382
409
|
const notes = [];
|
|
410
|
+
// A shallow clone cannot answer "what is unreleased?" — and it does not fail
|
|
411
|
+
// when asked, which is the dangerous part. `git log <tag>..<ref>` excludes
|
|
412
|
+
// everything reachable from <tag>, and that exclusion needs full ancestry;
|
|
413
|
+
// in a grafted history it silently under-applies and the range returns
|
|
414
|
+
// commits that were released long ago. Observed on this repo: a depth-1
|
|
415
|
+
// checkout of main reported 1 unreleased commit for a component that a full
|
|
416
|
+
// clone correctly reported as 0 — which would have proposed a patch release
|
|
417
|
+
// for nothing. A wrong commit list also means a wrong suggestedBump, so this
|
|
418
|
+
// is a blocker rather than a note: every number below it is untrustworthy.
|
|
419
|
+
if (git(['rev-parse', '--is-shallow-repository'], { cwd: repoPath }).stdout.trim() === 'true') {
|
|
420
|
+
blockers.push({
|
|
421
|
+
id: 'shallow-clone',
|
|
422
|
+
detail: 'this is a shallow clone, so commit ranges and the bump derived from them cannot be trusted — run `git fetch --unshallow` first',
|
|
423
|
+
});
|
|
424
|
+
}
|
|
383
425
|
if (!fetched || fetched.status !== 0) {
|
|
384
426
|
notes.push(`could not fetch origin (${fetched?.stderr || 'unknown error'}) — versions and tags below may be stale`);
|
|
385
427
|
}
|
|
386
428
|
if (!onMain.ok) blockers.push({ id: 'version-unreadable-on-main', detail: onMain.error });
|
|
387
|
-
if (!onDev.ok) blockers.push({ id: 'version-unreadable-on-dev', detail: onDev.error });
|
|
429
|
+
if (devBranch && !onDev.ok) blockers.push({ id: 'version-unreadable-on-dev', detail: onDev.error });
|
|
388
430
|
|
|
389
431
|
const changelogAbs = join(repoPath, component.changelog);
|
|
390
432
|
if (!existsSync(changelogAbs)) {
|
|
@@ -419,21 +461,57 @@ export function readStatus(repoPath, config, name) {
|
|
|
419
461
|
state = 'untagged-bump-on-main'; // never released; whatever is on main is the first release
|
|
420
462
|
}
|
|
421
463
|
|
|
464
|
+
// A fact, not a state — computed independently of the branch above so it is
|
|
465
|
+
// ALSO set when lastVersion is null (a component's first release). Folding
|
|
466
|
+
// this into `state` is the bug this field exists to fix (#173): "main has an
|
|
467
|
+
// untagged bump" and "dev already carries something higher" are
|
|
468
|
+
// independently true, and a single mutually-exclusive `state` string can
|
|
469
|
+
// only ever report one of them. `cut()`'s fast path acts on `state` alone —
|
|
470
|
+
// without `devAhead`, it would dispatch a release for whatever is on main
|
|
471
|
+
// while the version actually being released sits unread on dev.
|
|
472
|
+
const devAhead = onMain.ok && onDev.ok && cmpSemver(onDev.version, onMain.version) > 0
|
|
473
|
+
? { version: onDev.version, aheadOfMain: true }
|
|
474
|
+
: null;
|
|
475
|
+
|
|
476
|
+
// The fast path is only armed in `untagged-bump-on-main`, so this blocker is
|
|
477
|
+
// deliberately scoped to that state alone. `bump-on-dev-unpromoted` also has
|
|
478
|
+
// `devAhead` set — that is its normal, expected shape (no fast path is
|
|
479
|
+
// reachable there, nothing can be mis-tagged) — and flagging it too would
|
|
480
|
+
// permanently mark a routine state as blocked, which is how a blocker stops
|
|
481
|
+
// being read.
|
|
482
|
+
if (devAhead && state === 'untagged-bump-on-main') {
|
|
483
|
+
blockers.push({
|
|
484
|
+
id: 'dev-ahead-of-main',
|
|
485
|
+
detail: `${mainBranch} carries ${onMain.version} but ${devBranch} carries ${devAhead.version} — cutting here would tag ` +
|
|
486
|
+
`${tagFor(component, onMain.version)}, not ${tagFor(component, devAhead.version)}. Promote ${devBranch} → ${mainBranch} ` +
|
|
487
|
+
`and re-run status, or pass --version ${onMain.version} to release exactly what is on ${mainBranch}.`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
422
491
|
const since = commitsSince(repoPath, component, lastTag, mainRef);
|
|
423
492
|
const suggestion = suggestBump(since.commits, onMain.version ?? '0.0.0');
|
|
424
493
|
const nextVersion = suggestion.bump && onMain.ok ? bumpSemver(onMain.version, suggestion.bump) : null;
|
|
425
494
|
|
|
426
|
-
const collateral = collateralComponents(repoPath, config, name, devRef);
|
|
495
|
+
const collateral = devRef ? collateralComponents(repoPath, config, name, devRef) : [];
|
|
496
|
+
const pendingComponents = collateralComponents(repoPath, config, name, mainRef);
|
|
427
497
|
|
|
428
498
|
// The TOCTOU guard for cut(). Everything that could change the meaning of a
|
|
429
499
|
// release decision between the moment it is shown to a human and the moment
|
|
430
500
|
// it is acted on: both branch heads, the versions, the last tag, and who
|
|
431
501
|
// else is riding along.
|
|
502
|
+
const preparedBranches = git(['for-each-ref', '--format=%(refname)',
|
|
503
|
+
`refs/heads/feature/release-${name}-v*`, `refs/remotes/origin/feature/release-${name}-v*`], { cwd: repoPath })
|
|
504
|
+
.stdout.split('\n').filter(Boolean).sort().map((ref) => {
|
|
505
|
+
const version = readVersionAt(repoPath, component, ref).version;
|
|
506
|
+
return { ref, sha: revParse(repoPath, ref), version, notes: notesAt(repoPath, component, ref, version) };
|
|
507
|
+
});
|
|
432
508
|
const statusHash = sha256(
|
|
433
509
|
JSON.stringify({
|
|
434
510
|
component: name,
|
|
511
|
+
preparedBranches,
|
|
435
512
|
mainSha: revParse(repoPath, mainRef),
|
|
436
|
-
|
|
513
|
+
workflowPattern: policy.workflowPattern,
|
|
514
|
+
devSha: devRef ? revParse(repoPath, devRef) : null,
|
|
437
515
|
versionOnMain: onMain.version,
|
|
438
516
|
versionOnDev: onDev.version,
|
|
439
517
|
lastTag,
|
|
@@ -450,9 +528,16 @@ export function readStatus(repoPath, config, name) {
|
|
|
450
528
|
paths: component.paths,
|
|
451
529
|
inferredLayout: component.inferredLayout,
|
|
452
530
|
},
|
|
531
|
+
workflowPattern: policy.workflowPattern,
|
|
532
|
+
releaseBase: policy.base,
|
|
533
|
+
mainBranch,
|
|
534
|
+
devBranch,
|
|
535
|
+
pendingComponents,
|
|
536
|
+
preparedBranches,
|
|
453
537
|
state,
|
|
454
538
|
versionOnMain: onMain.version,
|
|
455
539
|
versionOnDev: onDev.version,
|
|
540
|
+
devAhead,
|
|
456
541
|
versionSources: onMain.sources,
|
|
457
542
|
lastTag,
|
|
458
543
|
commits: since.commits,
|
|
@@ -469,6 +554,13 @@ export function readStatus(repoPath, config, name) {
|
|
|
469
554
|
|
|
470
555
|
// ─── prepare ─────────────────────────────────────────────────────────────────
|
|
471
556
|
function writeVersionInto(relPath, text, version) {
|
|
557
|
+
if (relPath.endsWith('package-lock.json')) {
|
|
558
|
+
const data = JSON.parse(text);
|
|
559
|
+
if (!data.version) return null;
|
|
560
|
+
data.version = version;
|
|
561
|
+
if (data.packages?.['']?.version) data.packages[''].version = version;
|
|
562
|
+
return JSON.stringify(data, null, 2) + '\n';
|
|
563
|
+
}
|
|
472
564
|
if (relPath.endsWith('.json')) {
|
|
473
565
|
// Line-targeted rather than JSON.parse → JSON.stringify: reserializing
|
|
474
566
|
// would reformat the whole file (key order, indentation, trailing
|
|
@@ -534,11 +626,12 @@ export function spliceChangelog(existing, version, notes, date) {
|
|
|
534
626
|
}
|
|
535
627
|
|
|
536
628
|
export const releaseBranchName = (name, version) => `feature/release-${name}-v${version}`;
|
|
537
|
-
const worktreeDir = (name, version) => join(tmpdir(), `shipflow-release-${name}-${version}`);
|
|
629
|
+
const worktreeDir = (repoPath, name, version) => join(tmpdir(), `shipflow-release-${sha256(resolve(repoPath)).slice(0, 16)}-${name}-${version}`);
|
|
538
630
|
|
|
539
631
|
export function prepare(repoPath, config, name, version, notes, { date, featureBranchPrefix } = {}) {
|
|
540
632
|
const component = resolveComponent(repoPath, config, name);
|
|
541
|
-
const
|
|
633
|
+
const policy = branchPolicy(config);
|
|
634
|
+
const devBranch = policy.base;
|
|
542
635
|
const tag = tagFor(component, version);
|
|
543
636
|
const stamp = date ?? new Date().toISOString().slice(0, 10);
|
|
544
637
|
|
|
@@ -557,7 +650,7 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
557
650
|
if (featureBranchPrefix && !branch.startsWith(featureBranchPrefix)) {
|
|
558
651
|
return { ok: false, error: `release branch ${branch} does not start with the configured featureBranchPrefix ${featureBranchPrefix}` };
|
|
559
652
|
}
|
|
560
|
-
const dir = worktreeDir(name, version);
|
|
653
|
+
const dir = worktreeDir(repoPath, name, version);
|
|
561
654
|
|
|
562
655
|
// A leftover worktree from an aborted run must not silently become the base
|
|
563
656
|
// for this one — remove it, then re-create from the CURRENT dev.
|
|
@@ -569,7 +662,9 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
569
662
|
if (added.status !== 0) return { ok: false, error: `git worktree add failed: ${added.stderr}` };
|
|
570
663
|
|
|
571
664
|
const changed = [];
|
|
665
|
+
const dualHost = component.versionFiles.includes(`skills/${name}/.codex-plugin/plugin.json`);
|
|
572
666
|
try {
|
|
667
|
+
if (dualHost) checkDualHost(dir, true);
|
|
573
668
|
for (const relPath of component.versionFiles) {
|
|
574
669
|
const abs = join(dir, relPath);
|
|
575
670
|
if (!existsSync(abs)) continue;
|
|
@@ -594,6 +689,16 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
594
689
|
writeFileSync(clAbs, spliced.content);
|
|
595
690
|
changed.push(component.changelog);
|
|
596
691
|
|
|
692
|
+
if (dualHost) {
|
|
693
|
+
const catalog = readFileSync(join(dir, '.agents/plugins/marketplace.json'), 'utf8');
|
|
694
|
+
checkDualHost(dir, false);
|
|
695
|
+
if (readFileSync(join(dir, '.agents/plugins/marketplace.json'), 'utf8') !== catalog) throw new Error('unexpected generated catalog edit during version-only preparation');
|
|
696
|
+
const allowed = new Set([...changed, '.agents/plugins/marketplace.json']);
|
|
697
|
+
const actual = dirtyPaths(dir, ['.']);
|
|
698
|
+
const unexpected = actual.filter((path) => !allowed.has(path));
|
|
699
|
+
if (unexpected.length) throw new Error(`unexpected generated edits: ${unexpected.join(', ')}`);
|
|
700
|
+
changed.push(...actual.filter((path) => !changed.includes(path)));
|
|
701
|
+
}
|
|
597
702
|
// Explicit pathspecs, never `git add -A`. The worktree should contain
|
|
598
703
|
// nothing else, but "should" is not a guarantee worth a release commit.
|
|
599
704
|
const staged = git(['add', '--', ...changed], { cwd: dir });
|
|
@@ -608,6 +713,79 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
608
713
|
}
|
|
609
714
|
}
|
|
610
715
|
|
|
716
|
+
// Fixed adapter for this repository's declared dual-host layout. Ordinary
|
|
717
|
+
// consumers never execute Python. Refuse pre-existing drift before rewriting.
|
|
718
|
+
function checkDualHost(dir, before) {
|
|
719
|
+
for (const file of ['tools/sync_codex.py', 'tools/check_compatibility.py']) {
|
|
720
|
+
if (!existsSync(join(dir, file))) throw new Error(`dual-host preparation requires ${file}`);
|
|
721
|
+
}
|
|
722
|
+
const commands = before
|
|
723
|
+
? [['tools/sync_codex.py', '--check'], ['tools/check_compatibility.py']]
|
|
724
|
+
: [['tools/sync_codex.py'], ['tools/sync_codex.py', '--check'], ['tools/check_compatibility.py']];
|
|
725
|
+
for (const args of commands) {
|
|
726
|
+
const result = spawnArgs('python3', args, { cwd: dir, env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
|
|
727
|
+
if (result.status !== 0) throw new Error(`dual-host ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ─── resolving the release target ────────────────────────────────────────────
|
|
732
|
+
// The one place a target version is decided. Before this existed, `cut()`
|
|
733
|
+
// derived it twice, ten lines apart — once preferring dev, once preferring
|
|
734
|
+
// main — and those two derivations could disagree. That disagreement IS #173:
|
|
735
|
+
// the fast path would tag whatever sat on main while the version actually
|
|
736
|
+
// being released sat, unread, on dev. `cut()` now calls this once, before any
|
|
737
|
+
// network call, and uses its result for both the dispatch and the tag it
|
|
738
|
+
// waits for, so there is no longer a code path where those two can differ.
|
|
739
|
+
//
|
|
740
|
+
// Pure function of a `readStatus()` result plus an optional operator-supplied
|
|
741
|
+
// `requestedVersion` (`--version`). `requestedVersion` is a CONFIRMATION, not
|
|
742
|
+
// a bypass: it is only ever accepted when it matches a version already
|
|
743
|
+
// present on `main` or `dev` in this status, so there is no value of it that
|
|
744
|
+
// releases a version which isn't actually on the branch being dispatched.
|
|
745
|
+
export function resolveReleaseTarget(status, requestedVersion = null) {
|
|
746
|
+
const { state, versionOnMain, versionOnDev, devAhead, component } = status;
|
|
747
|
+
if (requestedVersion && !parseSemver(requestedVersion)) return { ok: false, error: 'requested version is not valid semver' };
|
|
748
|
+
if (status.preparedVersion && requestedVersion === status.preparedVersion) {
|
|
749
|
+
return { ok: true, version: requestedVersion, via: 'prepared-branch' };
|
|
750
|
+
}
|
|
751
|
+
if (requestedVersion && ![versionOnMain, versionOnDev].includes(requestedVersion)) {
|
|
752
|
+
return { ok: false, error: `requested version ${requestedVersion} is not on main, dev, or a verified prepared branch` };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
if (state === 'untagged-bump-on-main') {
|
|
757
|
+
if (!devAhead) {
|
|
758
|
+
if (requestedVersion && requestedVersion !== versionOnMain) return { ok: false, error: 'requested version does not match main' };
|
|
759
|
+
// The common, unambiguous case: whatever is on main is the only
|
|
760
|
+
// candidate, dev has nothing higher.
|
|
761
|
+
return { ok: true, version: versionOnMain, via: 'dispatch-on-main' };
|
|
762
|
+
}
|
|
763
|
+
if (requestedVersion === versionOnMain) {
|
|
764
|
+
// Confirmed: release exactly what is on main, knowingly leaving dev's
|
|
765
|
+
// higher version for a later, separate release.
|
|
766
|
+
return { ok: true, version: versionOnMain, via: 'dispatch-on-main', confirmed: true };
|
|
767
|
+
}
|
|
768
|
+
if (requestedVersion === versionOnDev) {
|
|
769
|
+
return {
|
|
770
|
+
ok: false,
|
|
771
|
+
error: `${versionOnDev} is on dev but not on main — a dispatch on main cannot cut it. ` +
|
|
772
|
+
`Promote dev → main first, then re-run release-status.`,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
ok: false,
|
|
777
|
+
error: `${component.name}: main carries ${versionOnMain} but dev carries ${versionOnDev} — ambiguous which one ` +
|
|
778
|
+
`to release, so refusing to guess. Promote dev → main and re-run release-status to release ${versionOnDev}, ` +
|
|
779
|
+
`or pass --version ${versionOnMain} to release exactly what is on main.`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Every other state (`clean`, `bump-on-dev-unpromoted`, `version-behind-tag`)
|
|
784
|
+
// already has a single unambiguous candidate — dev, when it carries the
|
|
785
|
+
// prepared bump, else main — matching what `cut()` used before this existed.
|
|
786
|
+
return { ok: true, version: requestedVersion ?? versionOnDev ?? versionOnMain, via: 'prepared-branch' };
|
|
787
|
+
}
|
|
788
|
+
|
|
611
789
|
// ─── cut ─────────────────────────────────────────────────────────────────────
|
|
612
790
|
// Resumable and bounded on purpose. The full path (feature PR → checks → merge
|
|
613
791
|
// → promotion → auto-merge → release run → tag) routinely takes longer than a
|
|
@@ -628,10 +806,10 @@ function sleepSync(ms) {
|
|
|
628
806
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
629
807
|
}
|
|
630
808
|
|
|
631
|
-
export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15 } = {}) {
|
|
809
|
+
export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15, version = null } = {}) {
|
|
632
810
|
const component = resolveComponent(repoPath, config, name);
|
|
633
|
-
const
|
|
634
|
-
const
|
|
811
|
+
const policy = branchPolicy(config);
|
|
812
|
+
const { main: mainBranch, dev: devBranch } = policy;
|
|
635
813
|
const owner = ownerRepo.split('/')[0];
|
|
636
814
|
|
|
637
815
|
const status = readStatus(repoPath, config, name);
|
|
@@ -644,65 +822,92 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
|
|
|
644
822
|
}
|
|
645
823
|
}
|
|
646
824
|
|
|
647
|
-
|
|
825
|
+
// The ONLY place the release target is decided — see resolveReleaseTarget's
|
|
826
|
+
// own comment for why. Called before any network call, so an ambiguous
|
|
827
|
+
// three-way state (#173: main has an untagged bump AND dev carries
|
|
828
|
+
// something higher) is refused here rather than acted on by the fast path
|
|
829
|
+
// below.
|
|
830
|
+
if (version && parseSemver(version)) {
|
|
831
|
+
const preparedRef = releaseBranchName(name, version);
|
|
832
|
+
const prepared = readVersionAt(repoPath, component, preparedRef);
|
|
833
|
+
if (prepared.ok && prepared.version === version && version !== status.versionOnMain) status.preparedVersion = version;
|
|
834
|
+
}
|
|
835
|
+
const target = resolveReleaseTarget(status, version);
|
|
836
|
+
if (!target.ok) return { ok: false, error: target.error };
|
|
837
|
+
const targetVersion = target.version;
|
|
648
838
|
const tag = tagFor(component, targetVersion);
|
|
649
839
|
const branch = releaseBranchName(name, targetVersion);
|
|
840
|
+
const released = tagExistsOnRemote(repoPath, tag);
|
|
841
|
+
if (released.ok && released.exists) {
|
|
842
|
+
const rel = ghApiJson(`repos/${ownerRepo}/releases/tags/${tag}`);
|
|
843
|
+
return { ok: true, done: true, stage: 'tag', tag, targetVersion, releaseUrl: rel.ok ? rel.data?.html_url ?? null : null, note: 'already released' };
|
|
844
|
+
}
|
|
650
845
|
const deadline = Date.now() + waitSeconds * 1000;
|
|
651
846
|
const log = [];
|
|
847
|
+
const preparedExists = Boolean(revParse(repoPath, branch));
|
|
848
|
+
const onBase = readVersionAt(repoPath, component, `origin/${policy.base}`);
|
|
849
|
+
const baseHasVersion = onBase.ok && onBase.version === targetVersion;
|
|
850
|
+
const sourceRef = target.via === 'prepared-branch' ? (preparedExists ? branch : `origin/${policy.base}`) : `origin/${mainBranch}`;
|
|
851
|
+
const expectedNotes = notesAt(repoPath, component, sourceRef, targetVersion);
|
|
652
852
|
const note = (stage, msg) => log.push({ stage, msg });
|
|
653
853
|
|
|
654
854
|
// Fast path: the bump is already on main and simply was never tagged (a
|
|
655
855
|
// failed or cancelled push run). No PR is needed at all — dispatch and prove.
|
|
656
|
-
if (
|
|
657
|
-
const already = tagExistsOnRemote(repoPath,
|
|
856
|
+
if (target.via === 'dispatch-on-main') {
|
|
857
|
+
const already = tagExistsOnRemote(repoPath, tag);
|
|
658
858
|
if (already.ok && already.exists) {
|
|
659
|
-
return { ok: true, done: true, stage: 'tag', tag
|
|
859
|
+
return { ok: true, done: true, stage: 'tag', tag, targetVersion, note: 'already released' };
|
|
660
860
|
}
|
|
861
|
+
const verified = verifyDispatch(repoPath, component, mainBranch, targetVersion, expectedNotes);
|
|
862
|
+
if (!verified.ok) return verified;
|
|
661
863
|
const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
|
|
662
864
|
if (d.status !== 0) return { ok: false, error: `workflow dispatch failed: ${d.stderr}` };
|
|
663
865
|
note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch}`);
|
|
664
|
-
|
|
866
|
+
const result = waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, null);
|
|
867
|
+
return { ...result, targetVersion };
|
|
665
868
|
}
|
|
666
869
|
|
|
667
870
|
// 1. push the prepared branch
|
|
668
|
-
if (!
|
|
871
|
+
if (!preparedExists && !baseHasVersion) {
|
|
669
872
|
return { ok: false, error: `branch ${branch} does not exist — run release-prepare first` };
|
|
670
873
|
}
|
|
671
|
-
const dir = worktreeDir(name, targetVersion);
|
|
874
|
+
const dir = worktreeDir(repoPath, name, targetVersion);
|
|
672
875
|
const pushCwd = existsSync(dir) ? dir : repoPath;
|
|
673
|
-
if (!revParse(repoPath, `origin/${branch}`)) {
|
|
876
|
+
if (preparedExists && !baseHasVersion && !revParse(repoPath, `origin/${branch}`)) {
|
|
674
877
|
const pushed = git(['push', '-u', 'origin', branch], { cwd: pushCwd });
|
|
675
878
|
if (pushed.status !== 0) return { ok: false, error: `git push failed: ${pushed.stderr}` };
|
|
676
879
|
note('push', `pushed ${branch}`);
|
|
677
880
|
}
|
|
678
881
|
|
|
679
|
-
|
|
680
|
-
|
|
882
|
+
const featureBase = policy.base;
|
|
883
|
+
|
|
884
|
+
// 2. open the feature PR against the configured base
|
|
885
|
+
let featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, featureBase);
|
|
681
886
|
if (!featurePr) {
|
|
682
|
-
const devHasIt = readVersionAt(repoPath, component, `origin/${
|
|
887
|
+
const devHasIt = readVersionAt(repoPath, component, `origin/${featureBase}`);
|
|
683
888
|
if (devHasIt.ok && cmpSemver(devHasIt.version, targetVersion) >= 0) {
|
|
684
889
|
note('feature-merged', `${targetVersion} is already on ${devBranch}`);
|
|
685
890
|
} else {
|
|
686
891
|
const created = spawnArgs('gh', [
|
|
687
|
-
'pr', 'create', '--repo', ownerRepo, '--base',
|
|
892
|
+
'pr', 'create', '--repo', ownerRepo, '--base', featureBase, '--head', branch,
|
|
688
893
|
'--title', `chore(${name}): release v${targetVersion}`,
|
|
689
|
-
'--body', `Release ${tag}.\n\nVersion bump and CHANGELOG entry land together, in this one change —
|
|
894
|
+
'--body', `Release ${tag}.\n\nVersion bump and CHANGELOG entry land together, in this one change — an explicit dispatch reads both from main before creating the tag.`,
|
|
690
895
|
]);
|
|
691
896
|
if (created.status !== 0) return { ok: false, error: `gh pr create failed: ${created.stderr}` };
|
|
692
|
-
featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`,
|
|
897
|
+
featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, featureBase);
|
|
693
898
|
note('feature-pr', `opened #${featurePr}`);
|
|
694
899
|
}
|
|
695
900
|
}
|
|
696
901
|
|
|
697
902
|
// 3. wait for its checks, then squash it into dev
|
|
698
903
|
if (featurePr) {
|
|
699
|
-
const gate = waitForChecks(ownerRepo, featurePr, deadline, pollSeconds, log);
|
|
904
|
+
const gate = waitForChecks(ownerRepo, featurePr, featureBase, config, deadline, pollSeconds, log);
|
|
700
905
|
if (!gate.ok) return gate;
|
|
701
|
-
if (!gate.done) return { ok: true, done: false, stage: 'feature-pr', featurePr, tag, log, next: 'call release-cut again — waiting on the feature PR’s checks' };
|
|
702
|
-
const method = config?.mergeMethod?.featureToDevMethod ?? 'squash';
|
|
906
|
+
if (!gate.done) return { ok: true, done: false, stage: 'feature-pr', featurePr, tag, targetVersion, log, next: 'call release-cut again — waiting on the feature PR’s checks' };
|
|
907
|
+
const method = (devBranch ? config?.mergeMethod?.featureToDevMethod : config?.mergeMethod?.devToMainMethod) ?? 'squash';
|
|
703
908
|
const merged = spawnArgs('gh', ['pr', 'merge', String(featurePr), '--repo', ownerRepo, `--${method}`, '--delete-branch']);
|
|
704
909
|
if (merged.status !== 0) return { ok: false, error: `gh pr merge failed on the feature PR: ${merged.stderr}` };
|
|
705
|
-
note('feature-merged', `merged #${featurePr} into ${
|
|
910
|
+
note('feature-merged', `merged #${featurePr} into ${featureBase} (${method})`);
|
|
706
911
|
rmSync(dir, { recursive: true, force: true });
|
|
707
912
|
git(['worktree', 'prune'], { cwd: repoPath });
|
|
708
913
|
}
|
|
@@ -710,55 +915,143 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
|
|
|
710
915
|
// 4. open (or find) the dev → main promotion. shipflow's rendered auto-merge
|
|
711
916
|
// workflow turns on native auto-merge from here; nothing polls for it.
|
|
712
917
|
git(['fetch', 'origin', '--prune'], { cwd: repoPath });
|
|
713
|
-
let promotion =
|
|
714
|
-
if (
|
|
715
|
-
const created = spawnArgs('gh', [
|
|
716
|
-
'pr', 'create', '--repo', ownerRepo, '--base', mainBranch, '--head', devBranch,
|
|
717
|
-
'--title', `release: ${name} v${targetVersion}`,
|
|
718
|
-
'--body', releaseBody(name, targetVersion, status.collateral),
|
|
719
|
-
]);
|
|
720
|
-
if (created.status !== 0) return { ok: false, error: `gh pr create failed on the promotion: ${created.stderr}` };
|
|
918
|
+
let promotion = null;
|
|
919
|
+
if (devBranch) {
|
|
721
920
|
promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
921
|
+
if (!promotion) {
|
|
922
|
+
const created = spawnArgs('gh', [
|
|
923
|
+
'pr', 'create', '--repo', ownerRepo, '--base', mainBranch, '--head', devBranch,
|
|
924
|
+
'--title', `release: ${name} v${targetVersion}`,
|
|
925
|
+
'--body', releaseBody(name, targetVersion, status.collateral),
|
|
926
|
+
]);
|
|
927
|
+
if (created.status !== 0) return { ok: false, error: `gh pr create failed on the promotion: ${created.stderr}` };
|
|
928
|
+
promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
|
|
929
|
+
note('promotion-open', `opened promotion #${promotion}`);
|
|
930
|
+
} else {
|
|
931
|
+
note('promotion-open', `promotion #${promotion} already open`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// 5. wait for the promotion to auto-merge, then for the tag to appear
|
|
935
|
+
const landed = waitForMerge(ownerRepo, promotion, deadline, pollSeconds, log);
|
|
936
|
+
if (!landed.ok) return landed;
|
|
937
|
+
if (!landed.done) {
|
|
938
|
+
return { ok: true, done: false, stage: 'promotion-open', promotion, tag, targetVersion, log, next: 'call release-cut again — waiting on the promotion to auto-merge' };
|
|
939
|
+
}
|
|
725
940
|
}
|
|
726
941
|
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
942
|
+
// 6. The promotion landing cuts NOTHING on its own. Every caller's release
|
|
943
|
+
// job is `workflow_dispatch`-only by deliberate design, so that this line
|
|
944
|
+
// is the single point at which a tag is ever created — one named
|
|
945
|
+
// component, released because someone asked for it.
|
|
946
|
+
//
|
|
947
|
+
// This is load-bearing, not ceremony: until 2026-08-02 the release jobs
|
|
948
|
+
// also ran on `push`, and a `dev -> main` merge therefore tagged and npm-
|
|
949
|
+
// published everything bumped on dev, seconds after merging, with no
|
|
950
|
+
// dispatch involved. Removing `push` without adding this dispatch would
|
|
951
|
+
// leave cut() waiting forever for a tag nobody cuts.
|
|
952
|
+
//
|
|
953
|
+
// Safe to re-run: _release.yml no-ops on an existing tag, and its
|
|
954
|
+
// `concurrency: release-<skill>` group serialises a resumed call behind
|
|
955
|
+
// an in-flight one.
|
|
956
|
+
const already = tagExistsOnRemote(repoPath, tag);
|
|
957
|
+
if (!(already.ok && already.exists)) {
|
|
958
|
+
const verified = verifyDispatch(repoPath, component, mainBranch, targetVersion, expectedNotes);
|
|
959
|
+
if (!verified.ok) return verified;
|
|
960
|
+
const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
|
|
961
|
+
if (d.status !== 0) {
|
|
962
|
+
return { ok: false, error: `the promotion merged but dispatching ${component.workflowFile} failed: ${d.stderr}. Nothing is tagged; re-run release-cut to retry the dispatch.` };
|
|
963
|
+
}
|
|
964
|
+
note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch} — this, not the merge, is what cuts the tag`);
|
|
732
965
|
}
|
|
733
|
-
|
|
966
|
+
const result = waitForTag(repoPath, tag, deadline, pollSeconds, log, ownerRepo, promotion);
|
|
967
|
+
return { ...result, targetVersion };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function notesAt(repoPath, component, ref, version) {
|
|
971
|
+
const result = git(['show', `${ref}:${component.changelog}`], { cwd: repoPath });
|
|
972
|
+
if (result.status !== 0) return null;
|
|
973
|
+
const lines = result.stdout.split('\n');
|
|
974
|
+
const start = lines.findIndex((line) => line.startsWith('## ') && (line.slice(3).split(/\s+/)[0] === version || line.slice(3).split(/\s+/)[0] === `[${version}]`));
|
|
975
|
+
if (start < 0) return null;
|
|
976
|
+
const end = lines.findIndex((line, i) => i > start && line.startsWith('## '));
|
|
977
|
+
return lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim() || null;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function verifyDispatch(repoPath, component, mainBranch, version, expectedNotes) {
|
|
981
|
+
const fetched = git(['fetch', 'origin', '--tags', '--prune'], { cwd: repoPath });
|
|
982
|
+
if (fetched.status !== 0) return { ok: false, error: 'cannot fetch main before dispatch' };
|
|
983
|
+
const current = readVersionAt(repoPath, component, `origin/${mainBranch}`);
|
|
984
|
+
if (!current.ok || current.version !== version) return { ok: false, error: `main version changed before dispatch; expected ${version}` };
|
|
985
|
+
if (!expectedNotes || notesAt(repoPath, component, `origin/${mainBranch}`, version) !== expectedNotes) {
|
|
986
|
+
return { ok: false, error: 'main changelog missing or changed before dispatch' };
|
|
987
|
+
}
|
|
988
|
+
return { ok: true };
|
|
734
989
|
}
|
|
735
990
|
|
|
736
991
|
function releaseBody(name, version, collateral) {
|
|
737
992
|
const extra = collateral.length
|
|
738
|
-
? `\n\n**This promotion also
|
|
993
|
+
? `\n\n**This promotion also moves these bumps to main** (a promotion is atomic and carries all of dev): ` +
|
|
994
|
+
`${collateral.map((c) => `\`${c.tag}\``).join(', ')}. ` +
|
|
995
|
+
`They are **not** released by merging — the release jobs are \`workflow_dispatch\`-only — but each becomes ` +
|
|
996
|
+
`\`untagged-bump-on-main\`, one \`release-cut\` away from a tag.`
|
|
739
997
|
: '';
|
|
740
998
|
return `Promotes \`${name}\` v${version} to main.${extra}`;
|
|
741
999
|
}
|
|
742
1000
|
|
|
743
|
-
function waitForChecks(ownerRepo, prNumber, deadline, pollSeconds, log) {
|
|
1001
|
+
function waitForChecks(ownerRepo, prNumber, base, config, deadline, pollSeconds, log) {
|
|
1002
|
+
const protection = ghApiJson(`repos/${ownerRepo}/branches/${encodeURIComponent(base)}/protection`);
|
|
1003
|
+
if (!protection.ok && !/404/.test(protection.stderr)) return { ok: false, error: 'could not read required branch checks' };
|
|
1004
|
+
const rules = ghApiJson(`repos/${ownerRepo}/rules/branches/${encodeURIComponent(base)}`);
|
|
1005
|
+
if (!rules.ok) return { ok: false, error: 'could not read branch rules' };
|
|
1006
|
+
const checks = protection.data?.required_status_checks?.checks ?? [];
|
|
1007
|
+
const required = [
|
|
1008
|
+
...checks.map((c) => ({ context: c.context, appId: c.app_id })),
|
|
1009
|
+
...(protection.data?.required_status_checks?.contexts ?? [])
|
|
1010
|
+
.filter((context) => !checks.some((c) => c.context === context))
|
|
1011
|
+
.map((context) => ({ context })),
|
|
1012
|
+
...(rules.data ?? []).filter((r) => r.type === 'required_status_checks')
|
|
1013
|
+
.flatMap((r) => (r.parameters?.required_status_checks ?? []).map((c) => ({ context: c.context, appId: c.integration_id }))),
|
|
1014
|
+
...(base === branchPolicy(config).main && config?.protectionOwner !== 'external' ? config?.requiredChecks ?? [] : [])
|
|
1015
|
+
.map((context) => ({ context })),
|
|
1016
|
+
];
|
|
1017
|
+
const matchesRun = (required, run) => run.name === required.context &&
|
|
1018
|
+
(required.appId == null || required.appId === -1 || run.app?.id === required.appId);
|
|
1019
|
+
const matchesStatus = (required, status) => status.context === required.context &&
|
|
1020
|
+
(required.appId == null || required.appId === -1);
|
|
744
1021
|
for (;;) {
|
|
745
1022
|
const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
|
|
746
1023
|
if (!r.ok) return { ok: false, error: `could not read PR #${prNumber}: ${r.stderr}` };
|
|
747
1024
|
const sha = r.data?.head?.sha;
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
1025
|
+
const runs = [];
|
|
1026
|
+
const contexts = [];
|
|
1027
|
+
for (let page = 1; ; page++) {
|
|
1028
|
+
const cr = ghApiJson(`repos/${ownerRepo}/commits/${sha}/check-runs?per_page=100&page=${page}`);
|
|
1029
|
+
if (!cr.ok) return { ok: false, error: `could not read check runs: ${cr.stderr}` };
|
|
1030
|
+
const batch = cr.data?.check_runs ?? [];
|
|
1031
|
+
runs.push(...batch);
|
|
1032
|
+
if (batch.length < 100) break;
|
|
1033
|
+
}
|
|
1034
|
+
for (let page = 1; ; page++) {
|
|
1035
|
+
const cs = ghApiJson(`repos/${ownerRepo}/commits/${sha}/status?per_page=100&page=${page}`);
|
|
1036
|
+
if (!cs.ok) return { ok: false, error: 'could not read commit statuses' };
|
|
1037
|
+
const batch = cs.data?.statuses ?? [];
|
|
1038
|
+
contexts.push(...batch);
|
|
1039
|
+
if (batch.length < 100) break;
|
|
1040
|
+
}
|
|
1041
|
+
const missing = required.filter((r) => !runs.some((c) => matchesRun(r, c) && c.status === 'completed' && ['success', 'neutral', 'skipped'].includes(c.conclusion)) && !contexts.some((c) => matchesStatus(r, c) && c.state === 'success'));
|
|
1042
|
+
const failedStatuses = contexts.filter((c) => required.some((r) => matchesStatus(r, c)) && ['failure', 'error'].includes(c.state));
|
|
1043
|
+
if (failedStatuses.length) return { ok: false, error: `required statuses failed: ${failedStatuses.map((c) => c.context).join(', ')}` };
|
|
751
1044
|
const pending = runs.filter((c) => c.status !== 'completed');
|
|
752
1045
|
const failed = runs.filter((c) => c.status === 'completed' && !['success', 'neutral', 'skipped'].includes(c.conclusion));
|
|
753
1046
|
if (failed.length > 0) {
|
|
754
1047
|
return { ok: false, error: `checks failed on PR #${prNumber}: ${failed.map((c) => c.name).join(', ')} — fix them, then call release-cut again` };
|
|
755
1048
|
}
|
|
756
|
-
if (runs.length > 0 && pending.length === 0) {
|
|
1049
|
+
if ((runs.length > 0 || contexts.length > 0) && pending.length === 0 && missing.length === 0) {
|
|
757
1050
|
log.push({ stage: 'feature-pr', msg: `${runs.length} checks green` });
|
|
758
1051
|
return { ok: true, done: true };
|
|
759
1052
|
}
|
|
760
1053
|
if (Date.now() + pollSeconds * 1000 > deadline) {
|
|
761
|
-
log.push({ stage: 'feature-pr', msg: `${pending.length}/${runs.length} checks still running` });
|
|
1054
|
+
log.push({ stage: 'feature-pr', msg: `${pending.length}/${runs.length} checks still running; missing required checks: ${missing.map((r) => r.context).join(', ')}` });
|
|
762
1055
|
return { ok: true, done: false };
|
|
763
1056
|
}
|
|
764
1057
|
sleepSync(pollSeconds * 1000);
|
package/package.json
CHANGED
package/skill-invariants.json
CHANGED
|
@@ -105,6 +105,11 @@
|
|
|
105
105
|
"id": "ambiguous-pattern-no-silent-pick",
|
|
106
106
|
"pattern": "present all 3 (templates|patterns).{0,60}ask the user to choose",
|
|
107
107
|
"rationale": "Ambiguous/greenfield autodetection must never silently pick a workflow pattern — mirrors the existing protectionOwner disambiguation precedent (ambiguous-protection-owner-prompt)."
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "untagged-bump-is-not-permission-to-cut",
|
|
111
|
+
"pattern": "is not, by itself, permission to cut",
|
|
112
|
+
"rationale": "For legacy two-branch consumers only (GitHub flow never reads dev): #173: `untagged-bump-on-main` collapsed two independently-true facts (main has an untagged bump; dev already carries something higher) into one state string, and the fast path acted on it without checking which one was true — silently tagging the OLDER version while a human meant the one on dev. This line is what stops an agent (or a human skimming the state table) from treating the state name alone as a green light; `devAhead` and the refusal it drives are the actual gate."
|
|
108
113
|
}
|
|
109
114
|
],
|
|
110
115
|
"cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "release-status", "release-prepare", "release-cut", "rename-default-branch"],
|
|
@@ -120,6 +125,14 @@
|
|
|
120
125
|
],
|
|
121
126
|
"update_command": "node evals/baseline/update.mjs",
|
|
122
127
|
"rationale": "This monorepo dogfoods shipflow on itself, so .github/shipflow.json and the workflow rendered from it are a genuine input/output pair from a real `apply` run, and that config's renderedTemplateHashes is the receipt shipflow wrote at the time. The baseline re-runs config -> params -> render and asserts byte equality against the frozen golden, that the golden's sha256 still equals the recorded receipt, and that the frozen golden still equals the repo's live committed workflow (so the fixture cannot quietly go stale). Byte-exactness is correct here and nowhere else in the baseline suite: for a workflow file, one changed character is a behavior change to the repo's merge automation. The paired negative assertions (quote injection rejected, missing param throws, merge method actually reaches the output) stop the golden from passing while the validators rot -- the missing-param one found a real bug on its first run: a present-but-undefined param rendered the literal string 'undefined' into `branches: [...]`, installing a workflow that could never fire."
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"id": "dev-ahead-fast-path-refused",
|
|
131
|
+
"kind": "trap",
|
|
132
|
+
"test": "tests/release.test.mjs",
|
|
133
|
+
"fixtures": [],
|
|
134
|
+
"update_command": "these are regression tests built inline against a real temp git repo (makeThreeWayRepo / makeUnpromotedRepo) — nothing to refresh",
|
|
135
|
+
"rationale": "#173: release-cut's fast path used to act on `state` alone, so when main carried an untagged bump AND dev independently carried something higher, it silently tagged the OLDER version (main's) while reporting success -- hit for real during `/release eval` on 2026-08-03. Pinned against the exact reported shape (lastTag < main < dev) plus its D2 sibling (the same ambiguity on a component's never-released first bump, which a fix scoped only to the `cmpSemver > 0` branch would leave exposed). The known-bad half: cut() must refuse before any dispatch is ever reachable, naming both versions; --version must be a confirmation validated against what is actually on the named branch, never a bare bypass; and the dev-ahead-of-main blocker must stay two-sided -- absent in `clean` and in `bump-on-dev-unpromoted` (where devAhead is normal, expected shape, not an ambiguity), or a blocker that is always on for those states stops being read."
|
|
123
136
|
}
|
|
124
137
|
]
|
|
125
138
|
}
|
|
@@ -6,9 +6,13 @@ name: auto-merge to {{MAIN_BRANCH}}
|
|
|
6
6
|
# (handEditDetected) until an explicit --force is passed. Commit both files
|
|
7
7
|
# together in the same commit.
|
|
8
8
|
#
|
|
9
|
-
# GitHub Flow has no separate
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# GitHub Flow has no separate promotion branch. Only ready PRs from this
|
|
10
|
+
# repository are eligible for auto-merge; repository write access is the trust
|
|
11
|
+
# boundary. Forks (and missing head repositories) skip both jobs because ordinary
|
|
12
|
+
# pull_request events cannot supply repository secrets to forks. Maintainers
|
|
13
|
+
# review fork PRs and enable auto-merge or merge with their own credentials after
|
|
14
|
+
# required checks, and may add release-pending manually. No PR code is executed.
|
|
15
|
+
# Same-repository merged PRs receive an optional reminder, never a tag or release.
|
|
12
16
|
#
|
|
13
17
|
# GH_TOKEN uses config.release.releaseCredential, NOT a hardcoded
|
|
14
18
|
# secrets.GITHUB_TOKEN, because of GitHub's loop-prevention rule: a PR
|
|
@@ -25,7 +29,7 @@ name: auto-merge to {{MAIN_BRANCH}}
|
|
|
25
29
|
|
|
26
30
|
on:
|
|
27
31
|
pull_request:
|
|
28
|
-
types: [opened, reopened, synchronize, closed]
|
|
32
|
+
types: [opened, reopened, synchronize, ready_for_review, closed]
|
|
29
33
|
branches: [{{MAIN_BRANCH}}]
|
|
30
34
|
|
|
31
35
|
# Deny by default at the workflow level, grant per job. A workflow-level grant
|
|
@@ -39,20 +43,30 @@ on:
|
|
|
39
43
|
permissions: {}
|
|
40
44
|
|
|
41
45
|
jobs:
|
|
42
|
-
# Enables native GitHub auto-merge on open/reopen/synchronize — this job
|
|
46
|
+
# Enables native GitHub auto-merge on open/reopen/synchronize/ready — this job
|
|
43
47
|
# does NOT wait for checks itself; it turns on auto-merge and exits. The
|
|
44
48
|
# actual merge happens asynchronously, later, whenever GitHub's own
|
|
45
49
|
# required-checks gate is satisfied (see the design's discussion of why a
|
|
46
50
|
# bespoke polling/blocking job was rejected).
|
|
47
51
|
auto-merge:
|
|
48
|
-
if:
|
|
52
|
+
if: >-
|
|
53
|
+
github.event.action != 'closed' &&
|
|
54
|
+
github.event.pull_request.draft == false &&
|
|
55
|
+
github.event.pull_request.head.repo.full_name == github.repository
|
|
49
56
|
runs-on: ubuntu-latest
|
|
50
57
|
permissions:
|
|
51
58
|
pull-requests: write
|
|
52
59
|
steps:
|
|
53
60
|
- name: Enable auto-merge
|
|
54
|
-
run:
|
|
61
|
+
run: |
|
|
62
|
+
if [ -z "$GH_TOKEN" ]; then
|
|
63
|
+
echo "Skipping auto-merge: GH_TOKEN is unavailable or not configured."
|
|
64
|
+
exit 0
|
|
65
|
+
fi
|
|
66
|
+
gh pr merge --auto {{MERGE_FLAG}} "$PR_NUMBER" --repo "$PR_REPO"
|
|
55
67
|
env:
|
|
68
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
69
|
+
PR_REPO: ${{ github.repository }}
|
|
56
70
|
GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
|
|
57
71
|
|
|
58
72
|
# Fires once, when a PR actually merges (a separate event from the job
|
|
@@ -63,12 +77,20 @@ jobs:
|
|
|
63
77
|
label-release-pending:
|
|
64
78
|
if: >-
|
|
65
79
|
github.event.action == 'closed' &&
|
|
66
|
-
github.event.pull_request.merged == true
|
|
80
|
+
github.event.pull_request.merged == true &&
|
|
81
|
+
github.event.pull_request.head.repo.full_name == github.repository
|
|
67
82
|
runs-on: ubuntu-latest
|
|
68
83
|
permissions:
|
|
69
84
|
pull-requests: write
|
|
70
85
|
steps:
|
|
71
86
|
- name: Apply release-pending label
|
|
72
|
-
run:
|
|
87
|
+
run: |
|
|
88
|
+
if [ -z "$GH_TOKEN" ]; then
|
|
89
|
+
echo "Skipping release reminder: GH_TOKEN is unavailable or not configured."
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
gh pr edit "$PR_NUMBER" --add-label release-pending --repo "$PR_REPO"
|
|
73
93
|
env:
|
|
94
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
95
|
+
PR_REPO: ${{ github.repository }}
|
|
74
96
|
GH_TOKEN: ${{ secrets.{{RELEASE_CREDENTIAL_SECRET}} }}
|