@natjswenson/shipflow 0.6.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 +10 -0
- package/README.md +73 -6
- package/SKILL.md +81 -6
- package/bin/shipflow.js +1 -1
- package/lib/release.mjs +210 -52
- package/package.json +1 -1
- package/skill-invariants.json +1 -1
- package/templates/github-flow/main-automerge.yml.tmpl +31 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
5
15
|
## 0.6.0 (2026-08-03) — the ambiguous fast path is refused, not guessed
|
|
6
16
|
|
|
7
17
|
### Fixed
|
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,11 +211,29 @@ 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.
|
|
@@ -198,7 +273,7 @@ at all and `--component` may be omitted.
|
|
|
198
273
|
3. **Cut it, and prove it.**
|
|
199
274
|
```
|
|
200
275
|
npx -y @natjswenson/shipflow@latest release-cut --repo <path> --component <name> \
|
|
201
|
-
--expect-status-hash <hash-from-step-1> --wait 240
|
|
276
|
+
--version <prepared-or-confirmed-version> --expect-status-hash <hash-from-step-1> --wait 240
|
|
202
277
|
```
|
|
203
278
|
`--expect-status-hash` is mandatory (same TOCTOU discipline as `apply`'s `--expect-state-hash`);
|
|
204
279
|
`--skip-hash-check` is a named escape hatch, never a default.
|
|
@@ -209,7 +284,7 @@ at all and `--component` may be omitted.
|
|
|
209
284
|
re-run `release-status` to release what's on dev (the normal recovery), **or** add
|
|
210
285
|
`--version <x.y.z>` naming exactly the version on main, if you deliberately mean to release
|
|
211
286
|
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
|
|
287
|
+
bypass — it is only ever accepted when it matches a version already on main, dev, or the verified prepared branch; anything
|
|
213
288
|
else is refused the same as passing nothing.
|
|
214
289
|
|
|
215
290
|
**`release-cut` is resumable and bounded, and it will usually return `done: false`.** The full
|
|
@@ -219,8 +294,8 @@ at all and `--component` may be omitted.
|
|
|
219
294
|
derives every stage from live remote state and never from a record of what a previous call did,
|
|
220
295
|
so a resumed run and a fresh one are the same code path.
|
|
221
296
|
|
|
222
|
-
**
|
|
223
|
-
workflow itself, after the
|
|
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
|
|
224
299
|
is created in this repo, which is why a merge can no longer surprise anyone with a release.
|
|
225
300
|
|
|
226
301
|
4. **Report the tag, and only the tag.** `done: true` carries `tag` and `releaseUrl`, read back
|
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) {
|
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,9 +333,19 @@ 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
351
|
// Every OTHER component whose version at `dev` carries no tag. A promotion is
|
|
@@ -369,20 +380,28 @@ export function collateralComponents(repoPath, config, exceptName, devRef) {
|
|
|
369
380
|
return out;
|
|
370
381
|
}
|
|
371
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
|
+
|
|
372
391
|
export function readStatus(repoPath, config, name) {
|
|
373
392
|
const component = resolveComponent(repoPath, config, name);
|
|
374
|
-
const
|
|
375
|
-
const
|
|
393
|
+
const policy = branchPolicy(config);
|
|
394
|
+
const { main: mainBranch, dev: devBranch } = policy;
|
|
376
395
|
|
|
377
396
|
// Read from the REMOTE-tracking refs, not the local branches: a local `main`
|
|
378
397
|
// that has not been fetched in a week would compute a bump against a stale
|
|
379
398
|
// baseline and silently propose a version that is already tagged.
|
|
380
399
|
const fetched = git(['fetch', 'origin', '--tags', '--prune'], { cwd: repoPath });
|
|
381
400
|
const mainRef = revParse(repoPath, `origin/${mainBranch}`) ? `origin/${mainBranch}` : mainBranch;
|
|
382
|
-
const devRef = revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch;
|
|
401
|
+
const devRef = devBranch ? (revParse(repoPath, `origin/${devBranch}`) ? `origin/${devBranch}` : devBranch) : null;
|
|
383
402
|
|
|
384
403
|
const onMain = readVersionAt(repoPath, component, mainRef);
|
|
385
|
-
const onDev = readVersionAt(repoPath, component, devRef);
|
|
404
|
+
const onDev = devRef ? readVersionAt(repoPath, component, devRef) : { ok: false, version: null };
|
|
386
405
|
const lastVersion = latestVersionTagged(repoPath, component);
|
|
387
406
|
const lastTag = lastVersion ? tagFor(component, lastVersion) : null;
|
|
388
407
|
|
|
@@ -407,7 +426,7 @@ export function readStatus(repoPath, config, name) {
|
|
|
407
426
|
notes.push(`could not fetch origin (${fetched?.stderr || 'unknown error'}) — versions and tags below may be stale`);
|
|
408
427
|
}
|
|
409
428
|
if (!onMain.ok) blockers.push({ id: 'version-unreadable-on-main', detail: onMain.error });
|
|
410
|
-
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 });
|
|
411
430
|
|
|
412
431
|
const changelogAbs = join(repoPath, component.changelog);
|
|
413
432
|
if (!existsSync(changelogAbs)) {
|
|
@@ -473,17 +492,26 @@ export function readStatus(repoPath, config, name) {
|
|
|
473
492
|
const suggestion = suggestBump(since.commits, onMain.version ?? '0.0.0');
|
|
474
493
|
const nextVersion = suggestion.bump && onMain.ok ? bumpSemver(onMain.version, suggestion.bump) : null;
|
|
475
494
|
|
|
476
|
-
const collateral = collateralComponents(repoPath, config, name, devRef);
|
|
495
|
+
const collateral = devRef ? collateralComponents(repoPath, config, name, devRef) : [];
|
|
496
|
+
const pendingComponents = collateralComponents(repoPath, config, name, mainRef);
|
|
477
497
|
|
|
478
498
|
// The TOCTOU guard for cut(). Everything that could change the meaning of a
|
|
479
499
|
// release decision between the moment it is shown to a human and the moment
|
|
480
500
|
// it is acted on: both branch heads, the versions, the last tag, and who
|
|
481
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
|
+
});
|
|
482
508
|
const statusHash = sha256(
|
|
483
509
|
JSON.stringify({
|
|
484
510
|
component: name,
|
|
511
|
+
preparedBranches,
|
|
485
512
|
mainSha: revParse(repoPath, mainRef),
|
|
486
|
-
|
|
513
|
+
workflowPattern: policy.workflowPattern,
|
|
514
|
+
devSha: devRef ? revParse(repoPath, devRef) : null,
|
|
487
515
|
versionOnMain: onMain.version,
|
|
488
516
|
versionOnDev: onDev.version,
|
|
489
517
|
lastTag,
|
|
@@ -500,6 +528,12 @@ export function readStatus(repoPath, config, name) {
|
|
|
500
528
|
paths: component.paths,
|
|
501
529
|
inferredLayout: component.inferredLayout,
|
|
502
530
|
},
|
|
531
|
+
workflowPattern: policy.workflowPattern,
|
|
532
|
+
releaseBase: policy.base,
|
|
533
|
+
mainBranch,
|
|
534
|
+
devBranch,
|
|
535
|
+
pendingComponents,
|
|
536
|
+
preparedBranches,
|
|
503
537
|
state,
|
|
504
538
|
versionOnMain: onMain.version,
|
|
505
539
|
versionOnDev: onDev.version,
|
|
@@ -520,6 +554,13 @@ export function readStatus(repoPath, config, name) {
|
|
|
520
554
|
|
|
521
555
|
// ─── prepare ─────────────────────────────────────────────────────────────────
|
|
522
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
|
+
}
|
|
523
564
|
if (relPath.endsWith('.json')) {
|
|
524
565
|
// Line-targeted rather than JSON.parse → JSON.stringify: reserializing
|
|
525
566
|
// would reformat the whole file (key order, indentation, trailing
|
|
@@ -585,11 +626,12 @@ export function spliceChangelog(existing, version, notes, date) {
|
|
|
585
626
|
}
|
|
586
627
|
|
|
587
628
|
export const releaseBranchName = (name, version) => `feature/release-${name}-v${version}`;
|
|
588
|
-
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}`);
|
|
589
630
|
|
|
590
631
|
export function prepare(repoPath, config, name, version, notes, { date, featureBranchPrefix } = {}) {
|
|
591
632
|
const component = resolveComponent(repoPath, config, name);
|
|
592
|
-
const
|
|
633
|
+
const policy = branchPolicy(config);
|
|
634
|
+
const devBranch = policy.base;
|
|
593
635
|
const tag = tagFor(component, version);
|
|
594
636
|
const stamp = date ?? new Date().toISOString().slice(0, 10);
|
|
595
637
|
|
|
@@ -608,7 +650,7 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
608
650
|
if (featureBranchPrefix && !branch.startsWith(featureBranchPrefix)) {
|
|
609
651
|
return { ok: false, error: `release branch ${branch} does not start with the configured featureBranchPrefix ${featureBranchPrefix}` };
|
|
610
652
|
}
|
|
611
|
-
const dir = worktreeDir(name, version);
|
|
653
|
+
const dir = worktreeDir(repoPath, name, version);
|
|
612
654
|
|
|
613
655
|
// A leftover worktree from an aborted run must not silently become the base
|
|
614
656
|
// for this one — remove it, then re-create from the CURRENT dev.
|
|
@@ -620,7 +662,9 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
620
662
|
if (added.status !== 0) return { ok: false, error: `git worktree add failed: ${added.stderr}` };
|
|
621
663
|
|
|
622
664
|
const changed = [];
|
|
665
|
+
const dualHost = component.versionFiles.includes(`skills/${name}/.codex-plugin/plugin.json`);
|
|
623
666
|
try {
|
|
667
|
+
if (dualHost) checkDualHost(dir, true);
|
|
624
668
|
for (const relPath of component.versionFiles) {
|
|
625
669
|
const abs = join(dir, relPath);
|
|
626
670
|
if (!existsSync(abs)) continue;
|
|
@@ -645,6 +689,16 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
645
689
|
writeFileSync(clAbs, spliced.content);
|
|
646
690
|
changed.push(component.changelog);
|
|
647
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
|
+
}
|
|
648
702
|
// Explicit pathspecs, never `git add -A`. The worktree should contain
|
|
649
703
|
// nothing else, but "should" is not a guarantee worth a release commit.
|
|
650
704
|
const staged = git(['add', '--', ...changed], { cwd: dir });
|
|
@@ -659,6 +713,21 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
659
713
|
}
|
|
660
714
|
}
|
|
661
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
|
+
|
|
662
731
|
// ─── resolving the release target ────────────────────────────────────────────
|
|
663
732
|
// The one place a target version is decided. Before this existed, `cut()`
|
|
664
733
|
// derived it twice, ten lines apart — once preferring dev, once preferring
|
|
@@ -675,9 +744,18 @@ export function prepare(repoPath, config, name, version, notes, { date, featureB
|
|
|
675
744
|
// releases a version which isn't actually on the branch being dispatched.
|
|
676
745
|
export function resolveReleaseTarget(status, requestedVersion = null) {
|
|
677
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
|
+
|
|
678
755
|
|
|
679
756
|
if (state === 'untagged-bump-on-main') {
|
|
680
757
|
if (!devAhead) {
|
|
758
|
+
if (requestedVersion && requestedVersion !== versionOnMain) return { ok: false, error: 'requested version does not match main' };
|
|
681
759
|
// The common, unambiguous case: whatever is on main is the only
|
|
682
760
|
// candidate, dev has nothing higher.
|
|
683
761
|
return { ok: true, version: versionOnMain, via: 'dispatch-on-main' };
|
|
@@ -705,7 +783,7 @@ export function resolveReleaseTarget(status, requestedVersion = null) {
|
|
|
705
783
|
// Every other state (`clean`, `bump-on-dev-unpromoted`, `version-behind-tag`)
|
|
706
784
|
// already has a single unambiguous candidate — dev, when it carries the
|
|
707
785
|
// prepared bump, else main — matching what `cut()` used before this existed.
|
|
708
|
-
return { ok: true, version: versionOnDev ?? versionOnMain, via: 'prepared-branch' };
|
|
786
|
+
return { ok: true, version: requestedVersion ?? versionOnDev ?? versionOnMain, via: 'prepared-branch' };
|
|
709
787
|
}
|
|
710
788
|
|
|
711
789
|
// ─── cut ─────────────────────────────────────────────────────────────────────
|
|
@@ -730,8 +808,8 @@ function sleepSync(ms) {
|
|
|
730
808
|
|
|
731
809
|
export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHash = null, skipHashCheck = false, ownerRepo, pollSeconds = 15, version = null } = {}) {
|
|
732
810
|
const component = resolveComponent(repoPath, config, name);
|
|
733
|
-
const
|
|
734
|
-
const
|
|
811
|
+
const policy = branchPolicy(config);
|
|
812
|
+
const { main: mainBranch, dev: devBranch } = policy;
|
|
735
813
|
const owner = ownerRepo.split('/')[0];
|
|
736
814
|
|
|
737
815
|
const status = readStatus(repoPath, config, name);
|
|
@@ -749,13 +827,28 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
|
|
|
749
827
|
// three-way state (#173: main has an untagged bump AND dev carries
|
|
750
828
|
// something higher) is refused here rather than acted on by the fast path
|
|
751
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
|
+
}
|
|
752
835
|
const target = resolveReleaseTarget(status, version);
|
|
753
836
|
if (!target.ok) return { ok: false, error: target.error };
|
|
754
837
|
const targetVersion = target.version;
|
|
755
838
|
const tag = tagFor(component, targetVersion);
|
|
756
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
|
+
}
|
|
757
845
|
const deadline = Date.now() + waitSeconds * 1000;
|
|
758
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);
|
|
759
852
|
const note = (stage, msg) => log.push({ stage, msg });
|
|
760
853
|
|
|
761
854
|
// Fast path: the bump is already on main and simply was never tagged (a
|
|
@@ -765,6 +858,8 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
|
|
|
765
858
|
if (already.ok && already.exists) {
|
|
766
859
|
return { ok: true, done: true, stage: 'tag', tag, targetVersion, note: 'already released' };
|
|
767
860
|
}
|
|
861
|
+
const verified = verifyDispatch(repoPath, component, mainBranch, targetVersion, expectedNotes);
|
|
862
|
+
if (!verified.ok) return verified;
|
|
768
863
|
const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
|
|
769
864
|
if (d.status !== 0) return { ok: false, error: `workflow dispatch failed: ${d.stderr}` };
|
|
770
865
|
note('dispatch', `dispatched ${component.workflowFile} on ${mainBranch}`);
|
|
@@ -773,44 +868,46 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
|
|
|
773
868
|
}
|
|
774
869
|
|
|
775
870
|
// 1. push the prepared branch
|
|
776
|
-
if (!
|
|
871
|
+
if (!preparedExists && !baseHasVersion) {
|
|
777
872
|
return { ok: false, error: `branch ${branch} does not exist — run release-prepare first` };
|
|
778
873
|
}
|
|
779
|
-
const dir = worktreeDir(name, targetVersion);
|
|
874
|
+
const dir = worktreeDir(repoPath, name, targetVersion);
|
|
780
875
|
const pushCwd = existsSync(dir) ? dir : repoPath;
|
|
781
|
-
if (!revParse(repoPath, `origin/${branch}`)) {
|
|
876
|
+
if (preparedExists && !baseHasVersion && !revParse(repoPath, `origin/${branch}`)) {
|
|
782
877
|
const pushed = git(['push', '-u', 'origin', branch], { cwd: pushCwd });
|
|
783
878
|
if (pushed.status !== 0) return { ok: false, error: `git push failed: ${pushed.stderr}` };
|
|
784
879
|
note('push', `pushed ${branch}`);
|
|
785
880
|
}
|
|
786
881
|
|
|
787
|
-
|
|
788
|
-
|
|
882
|
+
const featureBase = policy.base;
|
|
883
|
+
|
|
884
|
+
// 2. open the feature PR against the configured base
|
|
885
|
+
let featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, featureBase);
|
|
789
886
|
if (!featurePr) {
|
|
790
|
-
const devHasIt = readVersionAt(repoPath, component, `origin/${
|
|
887
|
+
const devHasIt = readVersionAt(repoPath, component, `origin/${featureBase}`);
|
|
791
888
|
if (devHasIt.ok && cmpSemver(devHasIt.version, targetVersion) >= 0) {
|
|
792
889
|
note('feature-merged', `${targetVersion} is already on ${devBranch}`);
|
|
793
890
|
} else {
|
|
794
891
|
const created = spawnArgs('gh', [
|
|
795
|
-
'pr', 'create', '--repo', ownerRepo, '--base',
|
|
892
|
+
'pr', 'create', '--repo', ownerRepo, '--base', featureBase, '--head', branch,
|
|
796
893
|
'--title', `chore(${name}): release v${targetVersion}`,
|
|
797
|
-
'--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.`,
|
|
798
895
|
]);
|
|
799
896
|
if (created.status !== 0) return { ok: false, error: `gh pr create failed: ${created.stderr}` };
|
|
800
|
-
featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`,
|
|
897
|
+
featurePr = prNumberFor(ownerRepo, `${owner}:${branch}`, featureBase);
|
|
801
898
|
note('feature-pr', `opened #${featurePr}`);
|
|
802
899
|
}
|
|
803
900
|
}
|
|
804
901
|
|
|
805
902
|
// 3. wait for its checks, then squash it into dev
|
|
806
903
|
if (featurePr) {
|
|
807
|
-
const gate = waitForChecks(ownerRepo, featurePr, deadline, pollSeconds, log);
|
|
904
|
+
const gate = waitForChecks(ownerRepo, featurePr, featureBase, config, deadline, pollSeconds, log);
|
|
808
905
|
if (!gate.ok) return gate;
|
|
809
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' };
|
|
810
|
-
const method = config?.mergeMethod?.featureToDevMethod ?? 'squash';
|
|
907
|
+
const method = (devBranch ? config?.mergeMethod?.featureToDevMethod : config?.mergeMethod?.devToMainMethod) ?? 'squash';
|
|
811
908
|
const merged = spawnArgs('gh', ['pr', 'merge', String(featurePr), '--repo', ownerRepo, `--${method}`, '--delete-branch']);
|
|
812
909
|
if (merged.status !== 0) return { ok: false, error: `gh pr merge failed on the feature PR: ${merged.stderr}` };
|
|
813
|
-
note('feature-merged', `merged #${featurePr} into ${
|
|
910
|
+
note('feature-merged', `merged #${featurePr} into ${featureBase} (${method})`);
|
|
814
911
|
rmSync(dir, { recursive: true, force: true });
|
|
815
912
|
git(['worktree', 'prune'], { cwd: repoPath });
|
|
816
913
|
}
|
|
@@ -818,25 +915,28 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
|
|
|
818
915
|
// 4. open (or find) the dev → main promotion. shipflow's rendered auto-merge
|
|
819
916
|
// workflow turns on native auto-merge from here; nothing polls for it.
|
|
820
917
|
git(['fetch', 'origin', '--prune'], { cwd: repoPath });
|
|
821
|
-
let promotion =
|
|
822
|
-
if (
|
|
823
|
-
const created = spawnArgs('gh', [
|
|
824
|
-
'pr', 'create', '--repo', ownerRepo, '--base', mainBranch, '--head', devBranch,
|
|
825
|
-
'--title', `release: ${name} v${targetVersion}`,
|
|
826
|
-
'--body', releaseBody(name, targetVersion, status.collateral),
|
|
827
|
-
]);
|
|
828
|
-
if (created.status !== 0) return { ok: false, error: `gh pr create failed on the promotion: ${created.stderr}` };
|
|
918
|
+
let promotion = null;
|
|
919
|
+
if (devBranch) {
|
|
829
920
|
promotion = prNumberFor(ownerRepo, `${owner}:${devBranch}`, mainBranch);
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
+
}
|
|
834
933
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
+
}
|
|
840
940
|
}
|
|
841
941
|
|
|
842
942
|
// 6. The promotion landing cuts NOTHING on its own. Every caller's release
|
|
@@ -855,6 +955,8 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
|
|
|
855
955
|
// an in-flight one.
|
|
856
956
|
const already = tagExistsOnRemote(repoPath, tag);
|
|
857
957
|
if (!(already.ok && already.exists)) {
|
|
958
|
+
const verified = verifyDispatch(repoPath, component, mainBranch, targetVersion, expectedNotes);
|
|
959
|
+
if (!verified.ok) return verified;
|
|
858
960
|
const d = spawnArgs('gh', ['workflow', 'run', component.workflowFile, '--ref', mainBranch, '--repo', ownerRepo]);
|
|
859
961
|
if (d.status !== 0) {
|
|
860
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.` };
|
|
@@ -865,6 +967,27 @@ export function cut(repoPath, config, name, { waitSeconds = 240, expectStatusHas
|
|
|
865
967
|
return { ...result, targetVersion };
|
|
866
968
|
}
|
|
867
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 };
|
|
989
|
+
}
|
|
990
|
+
|
|
868
991
|
function releaseBody(name, version, collateral) {
|
|
869
992
|
const extra = collateral.length
|
|
870
993
|
? `\n\n**This promotion also moves these bumps to main** (a promotion is atomic and carries all of dev): ` +
|
|
@@ -875,25 +998,60 @@ function releaseBody(name, version, collateral) {
|
|
|
875
998
|
return `Promotes \`${name}\` v${version} to main.${extra}`;
|
|
876
999
|
}
|
|
877
1000
|
|
|
878
|
-
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);
|
|
879
1021
|
for (;;) {
|
|
880
1022
|
const r = ghApiJson(`repos/${ownerRepo}/pulls/${prNumber}`);
|
|
881
1023
|
if (!r.ok) return { ok: false, error: `could not read PR #${prNumber}: ${r.stderr}` };
|
|
882
1024
|
const sha = r.data?.head?.sha;
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
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(', ')}` };
|
|
886
1044
|
const pending = runs.filter((c) => c.status !== 'completed');
|
|
887
1045
|
const failed = runs.filter((c) => c.status === 'completed' && !['success', 'neutral', 'skipped'].includes(c.conclusion));
|
|
888
1046
|
if (failed.length > 0) {
|
|
889
1047
|
return { ok: false, error: `checks failed on PR #${prNumber}: ${failed.map((c) => c.name).join(', ')} — fix them, then call release-cut again` };
|
|
890
1048
|
}
|
|
891
|
-
if (runs.length > 0 && pending.length === 0) {
|
|
1049
|
+
if ((runs.length > 0 || contexts.length > 0) && pending.length === 0 && missing.length === 0) {
|
|
892
1050
|
log.push({ stage: 'feature-pr', msg: `${runs.length} checks green` });
|
|
893
1051
|
return { ok: true, done: true };
|
|
894
1052
|
}
|
|
895
1053
|
if (Date.now() + pollSeconds * 1000 > deadline) {
|
|
896
|
-
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(', ')}` });
|
|
897
1055
|
return { ok: true, done: false };
|
|
898
1056
|
}
|
|
899
1057
|
sleepSync(pollSeconds * 1000);
|
package/package.json
CHANGED
package/skill-invariants.json
CHANGED
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
{
|
|
110
110
|
"id": "untagged-bump-is-not-permission-to-cut",
|
|
111
111
|
"pattern": "is not, by itself, permission to cut",
|
|
112
|
-
"rationale": "#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."
|
|
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."
|
|
113
113
|
}
|
|
114
114
|
],
|
|
115
115
|
"cli_commands_referenced": ["detect", "plan", "apply", "releases", "release-dispatch", "release-status", "release-prepare", "release-cut", "rename-default-branch"],
|
|
@@ -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}} }}
|