@1aboveio/skills 0.18.0 → 0.19.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/README.md +2 -2
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +43 -23
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +1 -1
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +23 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +65 -15
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +1 -1
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +23 -1
- package/skills/engineering/resolve-issues/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/generated/workflow-repair-policy.json +55 -11
- package/skills/engineering/resolve-issues/scripts/run-state.mjs +4 -4
- package/skills/engineering/resolve-release/references/related-skills.md +1 -0
- package/skills/engineering/rush-issues/LICENSE +3 -0
- package/skills/engineering/rush-issues/SKILL.md +179 -0
- package/skills/engineering/rush-issues/agents/openai.yaml +9 -0
- package/skills/engineering/rush-issues/evals/evals.json +65 -0
- package/skills/engineering/rush-issues/references/canary.md +45 -0
- package/skills/engineering/rush-issues/references/cicd.md +37 -0
- package/skills/engineering/rush-issues/references/combine.md +51 -0
- package/skills/engineering/rush-issues/references/expire.md +55 -0
- package/skills/engineering/rush-issues/references/exploration.md +53 -0
- package/skills/engineering/rush-issues/references/implementation.md +66 -0
- package/skills/engineering/rush-issues/references/preflight.md +31 -0
- package/skills/engineering/rush-issues/references/profiling.md +78 -0
- package/skills/engineering/rush-issues/references/review.md +48 -0
- package/skills/engineering/rush-issues/references/shared-modules.md +66 -0
- package/skills/engineering/rush-issues/references/task-plan.md +110 -0
- package/skills/engineering/rush-issues/scripts/discover-models.mjs +9 -0
- package/skills/engineering/rush-issues/scripts/model-catalog.mjs +9 -0
- package/skills/engineering/rush-issues/scripts/preflight-models.mjs +466 -0
- package/skills/engineering/rush-release/LICENSE +3 -0
- package/skills/engineering/rush-release/SKILL.md +99 -0
- package/skills/engineering/rush-release/agents/openai.yaml +8 -0
- package/skills/engineering/rush-release/evals/evals.json +44 -0
- package/skills/engineering/rush-release/references/candidate.md +30 -0
- package/skills/engineering/rush-release/references/cut.md +66 -0
- package/skills/engineering/rush-release/references/preflight.md +47 -0
- package/skills/engineering/rush-release/references/publish.md +100 -0
- package/skills/engineering/rush-release/scripts/apply.mjs +185 -0
- package/skills/engineering/rush-release/scripts/green-head.mjs +231 -0
- package/skills/engineering/rush-release/scripts/plan.mjs +264 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Cut
|
|
2
|
+
|
|
3
|
+
Turn the frozen SHA into a versioned commit without taking later trunk commits
|
|
4
|
+
along for the ride.
|
|
5
|
+
|
|
6
|
+
## 1. Plan
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
node <skillsRoot>/rush-release/scripts/plan.mjs --sha <candidateSha> --json \
|
|
10
|
+
> /tmp/rush-release-plan.json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The plan names `baselineTag`, `currentVersion`, `bump`, `reason`,
|
|
14
|
+
`nextVersion`, `tag`, `changelogMarkdown`, and `versionFiles`.
|
|
15
|
+
|
|
16
|
+
SemVer (0.x treats breaking as minor unless the human asks for strict):
|
|
17
|
+
|
|
18
|
+
| Content since baseline | Bump |
|
|
19
|
+
|---|---|
|
|
20
|
+
| breaking (`feat!:`, `BREAKING CHANGE:`) | major (minor under 0.x) |
|
|
21
|
+
| `feat:` | minor |
|
|
22
|
+
| otherwise | patch |
|
|
23
|
+
|
|
24
|
+
Changelog entries come from conventional-commit subjects in
|
|
25
|
+
`baselineTag..candidateSha` (or the recent first-parent log when there is no
|
|
26
|
+
tag). Keep an existing `## [Unreleased]` body — derived entries merge with it,
|
|
27
|
+
they do not replace it.
|
|
28
|
+
|
|
29
|
+
## 2. Confirm
|
|
30
|
+
|
|
31
|
+
Show the frozen SHA, proposed version/tag, bump reason, changelog, version
|
|
32
|
+
files, and publisher. Wait for confirmation. A different bump the human names
|
|
33
|
+
wins, as long as it is still SemVer.
|
|
34
|
+
|
|
35
|
+
Do not create a branch, edit files, or tag before that confirmation.
|
|
36
|
+
|
|
37
|
+
## 3. Apply and commit
|
|
38
|
+
|
|
39
|
+
Branch from the frozen SHA only:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git switch -c release/<tag> <candidateSha>
|
|
43
|
+
jq -r '.changelogMarkdown' /tmp/rush-release-plan.json | \
|
|
44
|
+
node <skillsRoot>/rush-release/scripts/apply.mjs \
|
|
45
|
+
--version <nextVersion> --date <YYYY-MM-DD> \
|
|
46
|
+
--changelog-section - --json
|
|
47
|
+
git add -- <paths from apply>
|
|
48
|
+
git commit -m "chore(release): <nextVersion>"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`apply.mjs` requires the confirmed plan's `changelogMarkdown` on stdin, writes
|
|
52
|
+
that section, and bumps the discovered identity files. It refuses a missing or
|
|
53
|
+
empty changelog section rather than silently replacing the plan with a bare
|
|
54
|
+
version heading. If the human edited the notes, write the confirmed markdown
|
|
55
|
+
to stdin instead of the raw plan field.
|
|
56
|
+
|
|
57
|
+
Then:
|
|
58
|
+
|
|
59
|
+
- Diff must be metadata only. If product code changed, reset and stop.
|
|
60
|
+
- `git rev-parse HEAD^` must equal the frozen SHA.
|
|
61
|
+
- Do not rebase onto a newer `main`, merge `main` in, or squash onto a moved
|
|
62
|
+
tip. Any of those would tag commits this candidate check never saw.
|
|
63
|
+
|
|
64
|
+
The cut SHA is `HEAD` after the commit. It is the immutable PR-head identity;
|
|
65
|
+
the release identity is the provider's verified merged SHA on `main`.
|
|
66
|
+
Proceed to [publish.md](publish.md).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Preflight
|
|
2
|
+
|
|
3
|
+
Preflight has three outputs: trunk `main`, the publisher to watch, and the
|
|
4
|
+
files that carry the version. It does not pick the SHA, bump the version, or
|
|
5
|
+
push.
|
|
6
|
+
|
|
7
|
+
## 1. Trunk
|
|
8
|
+
|
|
9
|
+
Fetch `origin`. Confirm the release trunk is `main` from those fetched refs
|
|
10
|
+
(`origin/main` exists). A repo whose production branch is `master` or `release`
|
|
11
|
+
is not this workflow.
|
|
12
|
+
|
|
13
|
+
## 2. Publisher
|
|
14
|
+
|
|
15
|
+
Detect how a tag becomes a shipped artifact. Prefer what the human named;
|
|
16
|
+
otherwise look at the tree:
|
|
17
|
+
|
|
18
|
+
| Signal | Publisher |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `cloudbuild.yaml` / `cloudbuild.json`, or a Cloud Build trigger on `v*` / `v*-rc.*` tags | `cloudbuild` |
|
|
21
|
+
| `package.json` with a publish workflow, `publishConfig`, or an explicit npm release | `npm` |
|
|
22
|
+
| both | watch both |
|
|
23
|
+
| neither, and the human did not name one | refuse — nothing to watch |
|
|
24
|
+
|
|
25
|
+
Record `publisher` as `cloudbuild`, `npm`, or `both`. If a GitHub Actions
|
|
26
|
+
workflow publishes on `release: published` rather than on tag push, note that
|
|
27
|
+
`gh release create` is required at publish time.
|
|
28
|
+
|
|
29
|
+
## 3. Version files
|
|
30
|
+
|
|
31
|
+
List identity files present at the repo root (and only those):
|
|
32
|
+
|
|
33
|
+
- `package.json` (and `package-lock.json` when it exists)
|
|
34
|
+
- `pyproject.toml`
|
|
35
|
+
- `VERSION`
|
|
36
|
+
|
|
37
|
+
Do not bump nested workspace packages unless the human names them. The cut
|
|
38
|
+
must stay metadata-only.
|
|
39
|
+
|
|
40
|
+
## 4. Baseline
|
|
41
|
+
|
|
42
|
+
Read the current version from those files when present. List annotated/lightweight
|
|
43
|
+
release tags matching `vMAJOR.MINOR.PATCH` with no prerelease. The highest such
|
|
44
|
+
tag is the changelog baseline. A prerelease (`v1.2.0-rc.1`) is not a baseline.
|
|
45
|
+
|
|
46
|
+
Preflight succeeds when trunk, publisher, version files, and baseline are
|
|
47
|
+
recorded. Proceed to [candidate.md](candidate.md).
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Publish
|
|
2
|
+
|
|
3
|
+
Push the cut, tag it, and watch the publisher. The tag is the deploy trigger
|
|
4
|
+
in this lane, so a pushed tag that fails to ship **burns the version** — the
|
|
5
|
+
next attempt takes the next patch. Do not move or delete the tag to "reuse" it.
|
|
6
|
+
|
|
7
|
+
## 1. Push and merge the metadata PR
|
|
8
|
+
|
|
9
|
+
Push the immutable cut head, then open a PR against `main`. Do not create or
|
|
10
|
+
push the release tag yet:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
git push origin HEAD:refs/heads/release/<tag>
|
|
14
|
+
gh pr create --base main --head release/<tag> \
|
|
15
|
+
--title "chore(release): <nextVersion>" \
|
|
16
|
+
--body "Metadata-only GitHub Flow release cut from <candidateSha>."
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Deliver that PR through the repository's configured merge queue and its
|
|
20
|
+
stateful watcher. The queue must test the synthetic merged tree. Freeze the
|
|
21
|
+
provider-reported merge commit as `<mergedSha>` and independently verify the
|
|
22
|
+
PR's head was `<cutSha>`. A squash merge is refused because `<cutSha>` would
|
|
23
|
+
not be an ancestor of `<mergedSha>`.
|
|
24
|
+
|
|
25
|
+
If `main` is genuinely unprotected and direct pushes are the repository's
|
|
26
|
+
documented release path, a successful fast-forward of `<cutSha>` to `main`
|
|
27
|
+
may use `<cutSha>` as `<mergedSha>`. Never infer this from local state: re-fetch
|
|
28
|
+
and verify the remote ref.
|
|
29
|
+
|
|
30
|
+
## 2. Verify and tag the merged main SHA
|
|
31
|
+
|
|
32
|
+
The release tag must name a commit reachable from the remote default branch.
|
|
33
|
+
This is the ancestry gate used by release publishers to refuse off-main
|
|
34
|
+
releases:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
git fetch origin main
|
|
38
|
+
git merge-base --is-ancestor "<cutSha>" "<mergedSha>"
|
|
39
|
+
git merge-base --is-ancestor "<mergedSha>" origin/main
|
|
40
|
+
test "$(git rev-parse "<cutSha>^{tree}")" = \
|
|
41
|
+
"$(git rev-parse "<mergedSha>^{tree}")"
|
|
42
|
+
test "$(git show "<mergedSha>:package.json" | jq -r .version)" = "<nextVersion>"
|
|
43
|
+
git tag -a "<tag>" "<mergedSha>" -m "Release <tag>"
|
|
44
|
+
git push --no-verify origin "refs/tags/<tag>"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The tree equality check proves the provider merge introduced no post-freeze
|
|
48
|
+
`main` changes beyond the approved cut. If it fails, do not tag: abandon the
|
|
49
|
+
unpublished cut and restart candidate selection and planning from current
|
|
50
|
+
`main`. Use the repository's declared version file in the `git show` check
|
|
51
|
+
when it is not `package.json`. Re-read the exact required queue/CI result for
|
|
52
|
+
`<mergedSha>` before tagging. A green cut head is not evidence for a different
|
|
53
|
+
merge commit.
|
|
54
|
+
|
|
55
|
+
`--no-verify` is for tag refs, because a Mergify (or similar) pre-push hook
|
|
56
|
+
that rewrites `git push` into a stack push is correct for branches and wrong
|
|
57
|
+
for immutable tags.
|
|
58
|
+
|
|
59
|
+
Re-tagging the same SHA is a resume no-op. A tag that already names a
|
|
60
|
+
**different** SHA is a stop. There is no `--force`.
|
|
61
|
+
|
|
62
|
+
If preflight recorded a workflow that publishes only on
|
|
63
|
+
`release: published`, create the GitHub Release after the tag is on the
|
|
64
|
+
remote (`gh release create <tag> --notes-file …`). Otherwise the tag push is
|
|
65
|
+
the trigger.
|
|
66
|
+
|
|
67
|
+
## 3. Watch
|
|
68
|
+
|
|
69
|
+
Launch one background watcher; do not poll in the foreground.
|
|
70
|
+
|
|
71
|
+
**Cloud Build** (`publisher` `cloudbuild` or `both`):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
gcloud builds list --limit=1 --format='value(id,status,substitutions.TAG_NAME)' \
|
|
75
|
+
--filter="substitutions.TAG_NAME=<tag> OR tags=<tag>"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Success is `SUCCESS` for that tag. `FAILURE` / `TIMEOUT` / `CANCELLED` is a
|
|
79
|
+
hand-back. Load `cloud-debug` only to diagnose, not to rerun the release.
|
|
80
|
+
|
|
81
|
+
**npm** (`publisher` `npm` or `both`):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm view <package>@<nextVersion> version
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Success is stdout equal to `<nextVersion>`. An `E404` means "not published
|
|
88
|
+
yet" and the watcher continues. Any other npm error is inconclusive — stop
|
|
89
|
+
rather than treating it as unpublished or published.
|
|
90
|
+
|
|
91
|
+
When a GitHub Actions workflow is the npm publisher, `gh run list` /
|
|
92
|
+
`gh run watch` for the tag SHA is the wait; `npm view` remains the proof the
|
|
93
|
+
artifact exists.
|
|
94
|
+
|
|
95
|
+
## 4. Failure
|
|
96
|
+
|
|
97
|
+
If the watch fails, leave the tag in place and hand back: cut SHA, tag,
|
|
98
|
+
publisher logs, and that the version is burned. Do not delete the tag, do not
|
|
99
|
+
roll back production, and do not open a `resolve-release` traffic shift. A
|
|
100
|
+
fix ships as the next patch from a new green HEAD.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Write changelog section + version identity files. Does not commit.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// node apply.mjs --version <x.y.z> --date <YYYY-MM-DD> --changelog-section - [--json]
|
|
6
|
+
//
|
|
7
|
+
// Exit: 0 ok · 1 IO/parse failed · 2 usage
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { isMainModule } from '../../engineering-runtime/scripts/main-module.mjs'
|
|
11
|
+
import { discoverVersionFiles, parseSemver } from './plan.mjs'
|
|
12
|
+
|
|
13
|
+
export function insertChangelog(markdown, { version, date, section }) {
|
|
14
|
+
const heading = `## [${version}] - ${date}`
|
|
15
|
+
const body = String(section || '').trim() || heading
|
|
16
|
+
const existing = String(markdown || '')
|
|
17
|
+
if (!body.startsWith(heading)) {
|
|
18
|
+
const err = new Error(`changelog section must start with ${heading}`)
|
|
19
|
+
err.exitCode = 2
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
23
|
+
const versionHeading = new RegExp(`^## \\[${escapedVersion}\\][^\\n]*`, 'm').exec(existing)
|
|
24
|
+
if (versionHeading) {
|
|
25
|
+
const start = versionHeading.index
|
|
26
|
+
const rest = existing.slice(start)
|
|
27
|
+
const next = rest.slice(versionHeading[0].length).search(/^## \[/m)
|
|
28
|
+
const end = next === -1 ? existing.length : start + versionHeading[0].length + next
|
|
29
|
+
const actual = existing.slice(start, end).trim()
|
|
30
|
+
if (actual === body || actual.startsWith(`${body}\n`)) return existing
|
|
31
|
+
const err = new Error(`existing ${version} changelog section does not match the confirmed plan`)
|
|
32
|
+
err.exitCode = 1
|
|
33
|
+
throw err
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const unreleased = /^## \[Unreleased\]\s*\n/m.exec(existing)
|
|
37
|
+
if (unreleased) {
|
|
38
|
+
const start = unreleased.index + unreleased[0].length
|
|
39
|
+
const next = existing.slice(start).search(/^## \[/m)
|
|
40
|
+
const end = next === -1 ? existing.length : start + next
|
|
41
|
+
const kept = existing.slice(start, end).trim()
|
|
42
|
+
const combined = kept ? `${body}\n\n${kept}` : body
|
|
43
|
+
return `${existing.slice(0, unreleased.index)}## [Unreleased]\n\n${heading}\n${stripHeading(combined, version)}\n\n${existing.slice(end).replace(/^\n+/, '')}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const title = /^# .+\n/.exec(existing)
|
|
47
|
+
if (title) {
|
|
48
|
+
return `${existing.slice(0, title[0].length)}\n${body}\n\n${existing.slice(title[0].length).replace(/^\n+/, '')}`
|
|
49
|
+
}
|
|
50
|
+
return `# Changelog\n\n${body}\n`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stripHeading(text, version) {
|
|
54
|
+
return text.replace(new RegExp(`^## \\[${version}\\][^\\n]*\\n?`), '').trim()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function bumpPackageJson(text, version) {
|
|
58
|
+
const pkg = JSON.parse(text)
|
|
59
|
+
pkg.version = version
|
|
60
|
+
return `${JSON.stringify(pkg, null, 2)}\n`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function bumpPackageLock(text, version) {
|
|
64
|
+
const lock = JSON.parse(text)
|
|
65
|
+
lock.version = version
|
|
66
|
+
if (lock.packages && lock.packages[''] && typeof lock.packages[''] === 'object') {
|
|
67
|
+
lock.packages[''].version = version
|
|
68
|
+
}
|
|
69
|
+
return `${JSON.stringify(lock, null, 2)}\n`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function bumpPyproject(text, version) {
|
|
73
|
+
let inProject = false
|
|
74
|
+
let replaced = false
|
|
75
|
+
const lines = String(text).split('\n')
|
|
76
|
+
const out = lines.map((line) => {
|
|
77
|
+
if (/^\[project\]\s*$/.test(line) || /^\[tool\.poetry\]\s*$/.test(line)) inProject = true
|
|
78
|
+
else if (/^\[/.test(line)) inProject = false
|
|
79
|
+
if (inProject && /^version\s*=/.test(line)) {
|
|
80
|
+
replaced = true
|
|
81
|
+
return `version = "${version}"`
|
|
82
|
+
}
|
|
83
|
+
return line
|
|
84
|
+
})
|
|
85
|
+
if (!replaced) {
|
|
86
|
+
const err = new Error('pyproject.toml has no [project]/[tool.poetry] version')
|
|
87
|
+
err.exitCode = 1
|
|
88
|
+
throw err
|
|
89
|
+
}
|
|
90
|
+
return out.join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function applyRelease({
|
|
94
|
+
version,
|
|
95
|
+
date,
|
|
96
|
+
cwd = process.cwd(),
|
|
97
|
+
changelogSection = null,
|
|
98
|
+
now = new Date(),
|
|
99
|
+
} = {}) {
|
|
100
|
+
if (!parseSemver(version)) {
|
|
101
|
+
const err = new Error('usage: apply.mjs --version <x.y.z> --date <YYYY-MM-DD> --changelog-section - [--json]')
|
|
102
|
+
err.exitCode = 2
|
|
103
|
+
throw err
|
|
104
|
+
}
|
|
105
|
+
if (typeof changelogSection !== 'string' || changelogSection.trim() === '') {
|
|
106
|
+
const err = new Error('apply.mjs requires the confirmed plan changelog via --changelog-section -')
|
|
107
|
+
err.exitCode = 2
|
|
108
|
+
throw err
|
|
109
|
+
}
|
|
110
|
+
const stamp = date || now.toISOString().slice(0, 10)
|
|
111
|
+
const files = discoverVersionFiles(cwd)
|
|
112
|
+
const changed = []
|
|
113
|
+
const changelogPath = join(cwd, 'CHANGELOG.md')
|
|
114
|
+
const previous = existsSync(changelogPath) ? readFileSync(changelogPath, 'utf8') : ''
|
|
115
|
+
writeFileSync(changelogPath, insertChangelog(previous, { version, date: stamp, section: changelogSection }))
|
|
116
|
+
changed.push('CHANGELOG.md')
|
|
117
|
+
|
|
118
|
+
if (files.includes('package.json')) {
|
|
119
|
+
writeFileSync(join(cwd, 'package.json'), bumpPackageJson(readFileSync(join(cwd, 'package.json'), 'utf8'), version))
|
|
120
|
+
changed.push('package.json')
|
|
121
|
+
}
|
|
122
|
+
if (files.includes('package-lock.json')) {
|
|
123
|
+
writeFileSync(join(cwd, 'package-lock.json'), bumpPackageLock(readFileSync(join(cwd, 'package-lock.json'), 'utf8'), version))
|
|
124
|
+
changed.push('package-lock.json')
|
|
125
|
+
}
|
|
126
|
+
if (files.includes('pyproject.toml')) {
|
|
127
|
+
writeFileSync(join(cwd, 'pyproject.toml'), bumpPyproject(readFileSync(join(cwd, 'pyproject.toml'), 'utf8'), version))
|
|
128
|
+
changed.push('pyproject.toml')
|
|
129
|
+
}
|
|
130
|
+
if (files.includes('VERSION')) {
|
|
131
|
+
writeFileSync(join(cwd, 'VERSION'), `${version}\n`)
|
|
132
|
+
changed.push('VERSION')
|
|
133
|
+
}
|
|
134
|
+
return { version, date: stamp, changed }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readStdin() {
|
|
138
|
+
return readFileSync(0, 'utf8')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseArgs(argv) {
|
|
142
|
+
const opts = { json: false, version: null, date: null, changelogSection: null }
|
|
143
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
144
|
+
const arg = argv[i]
|
|
145
|
+
if (arg === '--json') opts.json = true
|
|
146
|
+
else if (arg === '--version') opts.version = argv[++i]
|
|
147
|
+
else if (arg === '--date') opts.date = argv[++i]
|
|
148
|
+
else if (arg === '--changelog-section') opts.changelogFrom = argv[++i]
|
|
149
|
+
else if (arg === '--help' || arg === '-h') opts.help = true
|
|
150
|
+
else {
|
|
151
|
+
const err = new Error(`unknown argument: ${arg}`)
|
|
152
|
+
err.exitCode = 2
|
|
153
|
+
throw err
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (opts.help) return opts
|
|
157
|
+
if (!opts.version || !opts.date || !opts.changelogFrom) {
|
|
158
|
+
const err = new Error('usage: apply.mjs --version <x.y.z> --date <YYYY-MM-DD> --changelog-section - [--json]')
|
|
159
|
+
err.exitCode = 2
|
|
160
|
+
throw err
|
|
161
|
+
}
|
|
162
|
+
if (opts.changelogFrom === '-') opts.changelogSection = readStdin()
|
|
163
|
+
else if (opts.changelogFrom) opts.changelogSection = opts.changelogFrom
|
|
164
|
+
return opts
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function main(argv = process.argv.slice(2)) {
|
|
168
|
+
const opts = parseArgs(argv)
|
|
169
|
+
if (opts.help) {
|
|
170
|
+
process.stdout.write('apply.mjs --version <x.y.z> --date <YYYY-MM-DD> --changelog-section - [--json]\n')
|
|
171
|
+
return 0
|
|
172
|
+
}
|
|
173
|
+
const result = applyRelease(opts)
|
|
174
|
+
process.stdout.write(opts.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.changed.join('\n')}\n`)
|
|
175
|
+
return 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isMainModule(import.meta.url)) {
|
|
179
|
+
try {
|
|
180
|
+
process.exitCode = main()
|
|
181
|
+
} catch (error) {
|
|
182
|
+
process.stderr.write(`${error.message}\n`)
|
|
183
|
+
process.exitCode = error.exitCode || 1
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Newest first-parent SHA on trunk whose required CI is already green.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// node green-head.mjs [--trunk main] [--max 50] [--json]
|
|
6
|
+
//
|
|
7
|
+
// Exit: 0 found · 1 no green head / inspect failed · 2 usage
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { isMainModule } from '../../engineering-runtime/scripts/main-module.mjs'
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_TRUNK = 'main'
|
|
12
|
+
export const DEFAULT_MAX = 50
|
|
13
|
+
const PASSING = new Set(['success', 'neutral', 'skipped'])
|
|
14
|
+
const FAILING = new Set(['failure', 'failed', 'error', 'timed_out', 'cancelled', 'action_required', 'stale', 'startup_failure'])
|
|
15
|
+
|
|
16
|
+
export function parseGithubRemote(url) {
|
|
17
|
+
const raw = String(url || '').trim()
|
|
18
|
+
const ssh = raw.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/)
|
|
19
|
+
if (ssh) return { owner: ssh[1], repo: ssh[2] }
|
|
20
|
+
const https = raw.match(/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/)
|
|
21
|
+
if (https) return { owner: https[1], repo: https[2] }
|
|
22
|
+
const sshUrl = raw.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/)
|
|
23
|
+
if (sshUrl) return { owner: sshUrl[1], repo: sshUrl[2] }
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function runGit(args, { cwd, allowFail = false } = {}) {
|
|
28
|
+
const result = spawnSync('git', args, { encoding: 'utf8', cwd })
|
|
29
|
+
if (result.status !== 0 && !allowFail) {
|
|
30
|
+
const err = new Error(result.stderr.trim() || `git ${args.join(' ')} failed`)
|
|
31
|
+
err.exitCode = 1
|
|
32
|
+
throw err
|
|
33
|
+
}
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ghApi(path, { cwd, paginate = false } = {}) {
|
|
38
|
+
const args = ['api', path]
|
|
39
|
+
if (paginate) args.push('--paginate', '--slurp')
|
|
40
|
+
const result = spawnSync('gh', args, { encoding: 'utf8', cwd })
|
|
41
|
+
if (result.status !== 0) {
|
|
42
|
+
const err = new Error(result.stderr.trim() || `gh api ${path} failed`)
|
|
43
|
+
err.exitCode = 1
|
|
44
|
+
err.status = result.status
|
|
45
|
+
throw err
|
|
46
|
+
}
|
|
47
|
+
return result.stdout ? JSON.parse(result.stdout) : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function requiredContexts(protection) {
|
|
51
|
+
if (!protection || typeof protection !== 'object') return []
|
|
52
|
+
const checks = protection.required_status_checks
|
|
53
|
+
if (!checks) return []
|
|
54
|
+
const names = []
|
|
55
|
+
for (const context of checks.contexts || []) names.push(String(context))
|
|
56
|
+
for (const check of checks.checks || []) {
|
|
57
|
+
if (check && check.context) names.push(String(check.context))
|
|
58
|
+
}
|
|
59
|
+
return [...new Set(names)]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function conclusionOf(run) {
|
|
63
|
+
return String(run?.conclusion || run?.status || '').toLowerCase()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function inspectGreen({ status, checkRuns, required }) {
|
|
67
|
+
const runs = Array.isArray(checkRuns)
|
|
68
|
+
? checkRuns.flatMap((page) => Array.isArray(page?.check_runs) ? page.check_runs : [page])
|
|
69
|
+
: checkRuns?.check_runs || []
|
|
70
|
+
const statuses = Array.isArray(status?.statuses) ? status.statuses : []
|
|
71
|
+
const state = String(status?.state || '').toLowerCase()
|
|
72
|
+
const byName = new Map()
|
|
73
|
+
for (const run of runs) {
|
|
74
|
+
const name = String(run.name || run.context || '')
|
|
75
|
+
if (name && !byName.has(name)) byName.set(name, run)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (required.length > 0) {
|
|
79
|
+
const missing = []
|
|
80
|
+
const pending = []
|
|
81
|
+
const failed = []
|
|
82
|
+
for (const name of required) {
|
|
83
|
+
const run = byName.get(name)
|
|
84
|
+
const statusContext = statuses.find((item) => item.context === name)
|
|
85
|
+
const token = run ? conclusionOf(run) : String(statusContext?.state || '').toLowerCase()
|
|
86
|
+
if (!token) missing.push(name)
|
|
87
|
+
else if (token === 'pending' || token === 'queued' || token === 'in_progress') pending.push(name)
|
|
88
|
+
else if (FAILING.has(token)) failed.push(name)
|
|
89
|
+
else if (!PASSING.has(token) && token !== 'success') failed.push(name)
|
|
90
|
+
}
|
|
91
|
+
const green = missing.length === 0 && pending.length === 0 && failed.length === 0
|
|
92
|
+
return { green, missing, pending, failed, required: true }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const failedStatuses = statuses.filter((item) => FAILING.has(String(item?.state || '').toLowerCase()))
|
|
96
|
+
const pendingStatuses = statuses.filter((item) => ['pending', 'queued', 'in_progress', ''].includes(String(item?.state || '').toLowerCase()))
|
|
97
|
+
const passingStatuses = statuses.filter((item) => PASSING.has(String(item?.state || '').toLowerCase()))
|
|
98
|
+
if (state === 'failure' || state === 'error' || failedStatuses.length > 0) {
|
|
99
|
+
return { green: false, missing: [], pending: [], failed: ['combined-status'], required: false }
|
|
100
|
+
}
|
|
101
|
+
const pendingRuns = runs.filter((run) => {
|
|
102
|
+
const token = conclusionOf(run)
|
|
103
|
+
return token === 'pending' || token === 'queued' || token === 'in_progress' || token === ''
|
|
104
|
+
})
|
|
105
|
+
const failedRuns = runs.filter((run) => FAILING.has(conclusionOf(run)))
|
|
106
|
+
const passingRuns = runs.filter((run) => PASSING.has(conclusionOf(run)))
|
|
107
|
+
if (failedRuns.length > 0) {
|
|
108
|
+
return { green: false, missing: [], pending: [], failed: failedRuns.map((run) => run.name), required: false }
|
|
109
|
+
}
|
|
110
|
+
if (pendingRuns.length > 0 || pendingStatuses.length > 0) {
|
|
111
|
+
return {
|
|
112
|
+
green: false,
|
|
113
|
+
missing: [],
|
|
114
|
+
pending: [
|
|
115
|
+
...pendingRuns.map((run) => run.name || 'check-run'),
|
|
116
|
+
...pendingStatuses.map((item) => item.context || 'status-context'),
|
|
117
|
+
],
|
|
118
|
+
failed: [],
|
|
119
|
+
required: false,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (state === 'success' || passingRuns.length > 0 || passingStatuses.length > 0) {
|
|
123
|
+
return { green: true, missing: [], pending: [], failed: [], required: false }
|
|
124
|
+
}
|
|
125
|
+
return { green: false, missing: ['no-checks'], pending: [], failed: [], required: false }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function pickGreenHead(shas, inspect) {
|
|
129
|
+
for (const sha of shas) {
|
|
130
|
+
const verdict = inspect(sha)
|
|
131
|
+
if (verdict.green) return { sha, verdict }
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loadProtection(owner, repo, trunk, api) {
|
|
137
|
+
try {
|
|
138
|
+
return api(`repos/${owner}/${repo}/branches/${trunk}/protection`)
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (/HTTP 404|Not Found|no required/i.test(error.message)) return null
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function findGreenHead({
|
|
146
|
+
trunk = DEFAULT_TRUNK,
|
|
147
|
+
max = DEFAULT_MAX,
|
|
148
|
+
cwd,
|
|
149
|
+
git = runGit,
|
|
150
|
+
api = (path, options = {}) => ghApi(path, { cwd, ...options }),
|
|
151
|
+
} = {}) {
|
|
152
|
+
git(['fetch', 'origin', trunk], { cwd })
|
|
153
|
+
const remote = parseGithubRemote(git(['remote', 'get-url', 'origin'], { cwd }).stdout)
|
|
154
|
+
if (!remote) {
|
|
155
|
+
const err = new Error('origin is not a GitHub remote')
|
|
156
|
+
err.exitCode = 1
|
|
157
|
+
throw err
|
|
158
|
+
}
|
|
159
|
+
const log = git(['log', `origin/${trunk}`, '--first-parent', '-n', String(max), '--format=%H'], { cwd })
|
|
160
|
+
const shas = log.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
|
|
161
|
+
if (shas.length === 0) {
|
|
162
|
+
const err = new Error(`no commits on origin/${trunk}`)
|
|
163
|
+
err.exitCode = 1
|
|
164
|
+
throw err
|
|
165
|
+
}
|
|
166
|
+
const protection = loadProtection(remote.owner, remote.repo, trunk, api)
|
|
167
|
+
const required = requiredContexts(protection)
|
|
168
|
+
const inspect = (sha) => {
|
|
169
|
+
const status = api(`repos/${remote.owner}/${remote.repo}/commits/${sha}/status`)
|
|
170
|
+
const checks = api(`repos/${remote.owner}/${remote.repo}/commits/${sha}/check-runs?per_page=100`, { paginate: true })
|
|
171
|
+
return inspectGreen({ status, checkRuns: checks, required })
|
|
172
|
+
}
|
|
173
|
+
const picked = pickGreenHead(shas, inspect)
|
|
174
|
+
if (!picked) {
|
|
175
|
+
const err = new Error('no-green-head')
|
|
176
|
+
err.exitCode = 1
|
|
177
|
+
throw err
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
trunk,
|
|
181
|
+
owner: remote.owner,
|
|
182
|
+
repo: remote.repo,
|
|
183
|
+
tipSha: shas[0],
|
|
184
|
+
candidateSha: picked.sha,
|
|
185
|
+
walked: shas.indexOf(picked.sha) + 1,
|
|
186
|
+
requiredChecks: required,
|
|
187
|
+
verdict: picked.verdict,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseArgs(argv) {
|
|
192
|
+
const opts = { trunk: DEFAULT_TRUNK, max: DEFAULT_MAX, json: false }
|
|
193
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
194
|
+
const arg = argv[i]
|
|
195
|
+
if (arg === '--json') opts.json = true
|
|
196
|
+
else if (arg === '--trunk') opts.trunk = argv[++i]
|
|
197
|
+
else if (arg === '--max') opts.max = Number(argv[++i])
|
|
198
|
+
else if (arg === '--help' || arg === '-h') opts.help = true
|
|
199
|
+
else {
|
|
200
|
+
const err = new Error(`unknown argument: ${arg}`)
|
|
201
|
+
err.exitCode = 2
|
|
202
|
+
throw err
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!opts.trunk || !Number.isInteger(opts.max) || opts.max < 1) {
|
|
206
|
+
const err = new Error('usage: green-head.mjs [--trunk main] [--max 50] [--json]')
|
|
207
|
+
err.exitCode = 2
|
|
208
|
+
throw err
|
|
209
|
+
}
|
|
210
|
+
return opts
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function main(argv = process.argv.slice(2)) {
|
|
214
|
+
const opts = parseArgs(argv)
|
|
215
|
+
if (opts.help) {
|
|
216
|
+
process.stdout.write('green-head.mjs [--trunk main] [--max 50] [--json]\n')
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
const result = findGreenHead({ trunk: opts.trunk, max: opts.max })
|
|
220
|
+
process.stdout.write(opts.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.candidateSha}\n`)
|
|
221
|
+
return 0
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (isMainModule(import.meta.url)) {
|
|
225
|
+
try {
|
|
226
|
+
process.exitCode = main()
|
|
227
|
+
} catch (error) {
|
|
228
|
+
process.stderr.write(`${error.message}\n`)
|
|
229
|
+
process.exitCode = error.exitCode || 1
|
|
230
|
+
}
|
|
231
|
+
}
|