@opengsd/gsd-core 1.20.0 → 1.20.1

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.
Files changed (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/bin/install.js +36 -40
  4. package/commands/gsd/new-milestone.md +2 -2
  5. package/commands/gsd/new-project.md +2 -2
  6. package/gsd-core/bin/gsd-tools.cjs +104 -70
  7. package/gsd-core/bin/lib/capability-registry.cjs +64 -64
  8. package/gsd-core/bin/lib/commands.cjs +19 -0
  9. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  10. package/gsd-core/bin/lib/init.cjs +7 -7
  11. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  12. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  13. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  14. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  15. package/gsd-core/bin/lib/phase.cjs +64 -15
  16. package/gsd-core/bin/lib/planning-snapshot.cjs +2 -2
  17. package/gsd-core/bin/lib/roadmap-parser.cjs +29 -0
  18. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  19. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  20. package/gsd-core/bin/lib/state-transition.cjs +12 -5
  21. package/gsd-core/bin/lib/state.cjs +2 -2
  22. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  23. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  24. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  25. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  26. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  27. package/hooks/gsd-agent-isolation-guard.js +1 -1
  28. package/hooks/gsd-cursor-subagent-start.js +1 -1
  29. package/hooks/lib/isolation-sentinel.js +77 -2
  30. package/package.json +1 -1
  31. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  32. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  33. package/scripts/changeset/lint.cjs +28 -3
  34. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  35. package/scripts/lib/platform-conformance-tier.generated.cjs +4 -0
  36. package/scripts/merge-commit-pr.cjs +186 -0
  37. package/scripts/release-ancestry.cjs +86 -0
  38. package/scripts/release-pipeline-pr.cjs +92 -0
  39. package/scripts/require-issue-link-policy.cjs +18 -9
  40. package/scripts/sync-next-version.cjs +11 -2
  41. package/skills/gsd-new-milestone/SKILL.md +1 -1
  42. package/skills/gsd-new-project/SKILL.md +1 -1
  43. package/vscode/package.json +1 -1
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Land one of the release pipeline's history-carrying PRs as a merge commit (#5894).
5
+ *
6
+ * Two PRs must keep a parent edge, or main and next stop sharing history and
7
+ * the next release -> main merge conflicts in every version field:
8
+ *
9
+ * release-merge release/X.Y.0 | hotfix/X.Y.Z -> main keeps the v<version> tag in main
10
+ * main-to-next-backmerge chore/backmerge-main-to-next-<sha> -> next keeps main an ancestor of next
11
+ *
12
+ * v1.19.0 broke both (#5729 and #5730 were squash-merged). This script is the
13
+ * one sanctioned way to merge them, from auto-backmerge.yml, release.yml and a
14
+ * maintainer's shell alike:
15
+ *
16
+ * 1. it merges through `PUT /repos/{repo}/pulls/{n}/merge` with
17
+ * merge_method=merge: never the merge queue, which squashes, and not
18
+ * `gh pr merge --admin`, which does not honor a ruleset bypass;
19
+ * 2. the merge is pinned to the head sha it read (`sha=`), so a push after
20
+ * the read is refused rather than merged unseen;
21
+ * 3. afterwards it proves the result: the merge commit has two parents, one
22
+ * of them the PR head, and the base branch now contains the PR head.
23
+ *
24
+ * Usage: node scripts/merge-commit-pr.cjs <pr-number> [--repo owner/name]
25
+ * Prints one JSON result line; exits 0 when merged and verified, else 1.
26
+ */
27
+
28
+ const { execFileSync } = require('node:child_process');
29
+ const { RELEASE_PIPELINE_PR_KIND, releasePipelinePrKind } = require('./release-pipeline-pr.cjs');
30
+ const { runMain } = require('./lib/cli-exit.cjs');
31
+
32
+ const MERGE_COMMIT_PR_REASON = Object.freeze({
33
+ MERGED: 'merged',
34
+ USAGE: 'usage',
35
+ PR_UNREADABLE: 'pr-unreadable',
36
+ NOT_OPEN: 'not-open',
37
+ NOT_A_HISTORY_PR: 'not-a-history-pr',
38
+ MERGE_REFUSED: 'merge-refused',
39
+ NOT_A_MERGE_COMMIT: 'not-a-merge-commit',
40
+ ANCESTRY_BROKEN: 'ancestry-broken',
41
+ TAG_NOT_IN_BASE: 'tag-not-in-base',
42
+ VERIFY_UNREADABLE: 'verify-unreadable',
43
+ });
44
+
45
+ const HISTORY_KINDS = Object.freeze([
46
+ RELEASE_PIPELINE_PR_KIND.RELEASE_MERGE,
47
+ RELEASE_PIPELINE_PR_KIND.MAIN_TO_NEXT_BACKMERGE,
48
+ ]);
49
+
50
+ const PR_NUMBER_RE = /^[1-9][0-9]*$/;
51
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
52
+ const GH_TIMEOUT_MS = 30000;
53
+ const GH_MERGE_TIMEOUT_MS = 60000;
54
+
55
+ /** The release tag a release/hotfix head branch publishes: release/1.20.0 -> v1.20.0. */
56
+ function releaseTagFor(headRef) {
57
+ const match = /^(?:release|hotfix)\/((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*))$/.exec(String(headRef || ''));
58
+ return match ? `v${match[1]}` : null;
59
+ }
60
+
61
+ /** Parse argv. Returns { prNumber, repo } or { error }. */
62
+ function parseArgs(argv, env = process.env) {
63
+ let prNumber = null;
64
+ let repo = env.GITHUB_REPOSITORY || 'open-gsd/gsd-core';
65
+ for (let i = 0; i < argv.length; i += 1) {
66
+ const arg = argv[i];
67
+ if (arg === '--repo') {
68
+ const value = argv[i + 1];
69
+ if (typeof value !== 'string' || !REPO_RE.test(value)) return { error: '--repo needs owner/name' };
70
+ repo = value;
71
+ i += 1;
72
+ } else if (prNumber === null && PR_NUMBER_RE.test(arg)) {
73
+ prNumber = Number(arg);
74
+ } else {
75
+ return { error: `unexpected argument: ${arg}` };
76
+ }
77
+ }
78
+ if (prNumber === null) return { error: 'usage: merge-commit-pr.cjs <pr-number> [--repo owner/name]' };
79
+ if (!REPO_RE.test(repo)) return { error: `invalid repository: ${repo}` };
80
+ return { prNumber, repo };
81
+ }
82
+
83
+ /** Default gh runner: returns stdout; throws on non-zero exit (err.stderr carries gh's text). */
84
+ function defaultGh(args, { timeout = GH_TIMEOUT_MS } = {}) {
85
+ return execFileSync('gh', args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] });
86
+ }
87
+
88
+ function errorText(err) {
89
+ return String((err && (err.stderr || err.message)) || err).trim();
90
+ }
91
+
92
+ /**
93
+ * Merge PR `prNumber` of `repo` with a merge commit and verify the history.
94
+ * `gh(args, opts)` returns gh's stdout (JSON for `gh api`) and throws on failure.
95
+ *
96
+ * @returns {{ ok: boolean, reason: string, kind?: string, mergeSha?: string, detail?: string }}
97
+ */
98
+ function mergeWithMergeCommit({ prNumber, repo, gh = defaultGh }) {
99
+ let pr;
100
+ try {
101
+ pr = JSON.parse(gh(['api', `repos/${repo}/pulls/${prNumber}`]));
102
+ } catch (err) {
103
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.PR_UNREADABLE, detail: errorText(err) };
104
+ }
105
+ if (!pr || pr.state !== 'open' || pr.merged === true) {
106
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_OPEN };
107
+ }
108
+ const head = pr.head || {};
109
+ const base = pr.base || {};
110
+ const headRepo = head.repo && typeof head.repo.full_name === 'string' ? head.repo.full_name : null;
111
+ const kind = releasePipelinePrKind({
112
+ headRef: head.ref,
113
+ baseRef: base.ref,
114
+ sameRepo: headRepo !== null && headRepo.toLowerCase() === repo.toLowerCase(),
115
+ });
116
+ if (!HISTORY_KINDS.includes(kind)) {
117
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_A_HISTORY_PR, kind: kind || undefined };
118
+ }
119
+ const headSha = head.sha;
120
+
121
+ let merge;
122
+ try {
123
+ merge = JSON.parse(gh(
124
+ ['api', '-X', 'PUT', `repos/${repo}/pulls/${prNumber}/merge`, '-f', 'merge_method=merge', '-f', `sha=${headSha}`],
125
+ { timeout: GH_MERGE_TIMEOUT_MS },
126
+ ));
127
+ } catch (err) {
128
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.MERGE_REFUSED, kind, detail: errorText(err) };
129
+ }
130
+ if (!merge || merge.merged !== true || typeof merge.sha !== 'string') {
131
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.MERGE_REFUSED, kind, detail: JSON.stringify(merge) };
132
+ }
133
+ const mergeSha = merge.sha;
134
+
135
+ let commit;
136
+ let compare;
137
+ try {
138
+ commit = JSON.parse(gh(['api', `repos/${repo}/commits/${mergeSha}`]));
139
+ compare = JSON.parse(gh(['api', `repos/${repo}/compare/${headSha}...${base.ref}`]));
140
+ } catch (err) {
141
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: errorText(err) };
142
+ }
143
+ const parents = commit && Array.isArray(commit.parents) ? commit.parents.map((p) => p && p.sha) : [];
144
+ if (parents.length !== 2 || !parents.includes(headSha)) {
145
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.NOT_A_MERGE_COMMIT, kind, mergeSha, detail: `parents=${parents.join(',')}` };
146
+ }
147
+ if (!compare || compare.behind_by !== 0) {
148
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.ANCESTRY_BROKEN, kind, mergeSha, detail: `behind_by=${compare && compare.behind_by}` };
149
+ }
150
+ if (kind === RELEASE_PIPELINE_PR_KIND.RELEASE_MERGE) {
151
+ const tag = releaseTagFor(head.ref);
152
+ if (tag === null) {
153
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: 'no release tag for ' + head.ref };
154
+ }
155
+ let tagCompare;
156
+ try {
157
+ tagCompare = JSON.parse(gh(['api', `repos/${repo}/compare/${tag}...${base.ref}`]));
158
+ } catch (err) {
159
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.VERIFY_UNREADABLE, kind, mergeSha, detail: errorText(err) };
160
+ }
161
+ if (!tagCompare || tagCompare.behind_by !== 0) {
162
+ return { ok: false, reason: MERGE_COMMIT_PR_REASON.TAG_NOT_IN_BASE, kind, mergeSha, detail: `${tag} behind_by=${tagCompare && tagCompare.behind_by}` };
163
+ }
164
+ }
165
+ return { ok: true, reason: MERGE_COMMIT_PR_REASON.MERGED, kind, mergeSha };
166
+ }
167
+
168
+ function main(argv = process.argv.slice(2), { gh = defaultGh, env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
169
+ const args = parseArgs(argv, env);
170
+ if (args.error) {
171
+ stderr.write(`::error::${args.error}\n`);
172
+ stdout.write(`${JSON.stringify({ ok: false, reason: MERGE_COMMIT_PR_REASON.USAGE })}\n`);
173
+ return 1;
174
+ }
175
+ const result = mergeWithMergeCommit({ prNumber: args.prNumber, repo: args.repo, gh });
176
+ stdout.write(`${JSON.stringify(result)}\n`);
177
+ if (!result.ok) {
178
+ stderr.write(`::error::PR #${args.prNumber} was not landed as a verified merge commit (${result.reason})${result.detail ? `: ${result.detail}` : ''}. `
179
+ + 'Never put a release or back-merge PR in the merge queue or squash it: main and next must keep sharing history (#5894).\n');
180
+ }
181
+ return result.ok ? 0 : 1;
182
+ }
183
+
184
+ if (require.main === module) runMain(main);
185
+
186
+ module.exports = { MERGE_COMMIT_PR_REASON, HISTORY_KINDS, GH_TIMEOUT_MS, GH_MERGE_TIMEOUT_MS, releaseTagFor, parseArgs, mergeWithMergeCommit, main };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Release ancestry check (#5894): `main` must be an ancestor of `next`.
5
+ *
6
+ * auto-backmerge.yml keeps that true by landing a merge commit of main into
7
+ * next after every release. When the back-merge is squashed (#5730), main and
8
+ * next stop sharing history and the next release -> main merge conflicts in
9
+ * every version field. release.yml runs this before cutting or finalizing a
10
+ * release so the break surfaces there, with the fix, instead of as a 91-file
11
+ * conflict on the merge-back PR.
12
+ *
13
+ * Usage: node scripts/release-ancestry.cjs [--ancestor origin/main] [--descendant origin/next]
14
+ * Exits 0 when the ancestor is reachable from the descendant, else 1.
15
+ */
16
+
17
+ const { execFileSync } = require('node:child_process');
18
+ const { runMain } = require('./lib/cli-exit.cjs');
19
+
20
+ const ANCESTRY_VERDICT = Object.freeze({
21
+ ANCESTOR: 'ancestor',
22
+ DIVERGED: 'diverged',
23
+ UNKNOWN: 'unknown',
24
+ });
25
+
26
+ const GIT_TIMEOUT_MS = 15000;
27
+ const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
28
+
29
+ /** Default git runner: returns { status } for `git <args>`. */
30
+ function defaultGit(args) {
31
+ try {
32
+ execFileSync('git', args, { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
33
+ return { status: 0 };
34
+ } catch (err) {
35
+ return { status: typeof err.status === 'number' ? err.status : null };
36
+ }
37
+ }
38
+
39
+ /**
40
+ * `git merge-base --is-ancestor` exits 0 (ancestor), 1 (not), or another code
41
+ * (an error, e.g. an unknown ref). Only 0 and 1 are verdicts; anything else is
42
+ * UNKNOWN, and the CLI treats UNKNOWN as a failure.
43
+ */
44
+ function checkAncestry({ ancestor, descendant, git = defaultGit }) {
45
+ const { status } = git(['merge-base', '--is-ancestor', ancestor, descendant]);
46
+ if (status === 0) return ANCESTRY_VERDICT.ANCESTOR;
47
+ if (status === 1) return ANCESTRY_VERDICT.DIVERGED;
48
+ return ANCESTRY_VERDICT.UNKNOWN;
49
+ }
50
+
51
+ function parseArgs(argv) {
52
+ const out = { ancestor: 'origin/main', descendant: 'origin/next' };
53
+ for (let i = 0; i < argv.length; i += 1) {
54
+ const flag = argv[i];
55
+ const value = argv[i + 1];
56
+ if ((flag === '--ancestor' || flag === '--descendant') && typeof value === 'string' && REF_RE.test(value)) {
57
+ out[flag.slice(2)] = value;
58
+ i += 1;
59
+ } else {
60
+ return { error: `unexpected argument: ${flag}` };
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function main(argv = process.argv.slice(2), { git = defaultGit, stdout = process.stdout, stderr = process.stderr } = {}) {
67
+ const args = parseArgs(argv);
68
+ if (args.error) {
69
+ stderr.write(`::error::${args.error}\n`);
70
+ return 1;
71
+ }
72
+ const verdict = checkAncestry({ ancestor: args.ancestor, descendant: args.descendant, git });
73
+ stdout.write(`${JSON.stringify({ verdict, ancestor: args.ancestor, descendant: args.descendant })}\n`);
74
+ if (verdict === ANCESTRY_VERDICT.ANCESTOR) return 0;
75
+ stderr.write(verdict === ANCESTRY_VERDICT.DIVERGED
76
+ ? `::error::${args.ancestor} is not an ancestor of ${args.descendant}, so the next release -> main merge will conflict (#5894). `
77
+ + 'Land a back-merge of main into next as a merge commit first: push chore/backmerge-main-to-next-<short sha of main> '
78
+ + '(next plus `git merge -s ours --no-ff origin/main`), open it against next, and merge it with '
79
+ + '`node scripts/merge-commit-pr.cjs <PR>` as a repo admin. Never send it through the merge queue.\n'
80
+ : `::error::could not read the ancestry of ${args.ancestor} and ${args.descendant} (git failed). Fetch both refs and retry.\n`);
81
+ return 1;
82
+ }
83
+
84
+ if (require.main === module) runMain(main);
85
+
86
+ module.exports = { ANCESTRY_VERDICT, GIT_TIMEOUT_MS, checkAncestry, parseArgs, main };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The release pipeline's own back-merge PRs (#5890).
5
+ *
6
+ * The pipeline opens three kinds of PR, and none of them has an issue to link:
7
+ *
8
+ * kind head branch base opened by
9
+ * release-merge release/X.Y.0 | hotfix/X.Y.Z (Z>0) main release.yml "merge release back to main"
10
+ * next-version-sync chore/sync-next-version-<version> next scripts/sync-next-version.cjs
11
+ * main-to-next-backmerge chore/backmerge-main-to-next-<sha> next auto-backmerge.yml (#1389)
12
+ *
13
+ * Callers, each with its trust model:
14
+ *
15
+ * - scripts/require-issue-link-policy.cjs (`Issue link required`) and
16
+ * .github/workflows/pr-title-validator.yml (`validate-title`) run on
17
+ * pull_request_target and load this module from a BASE-branch checkout,
18
+ * so a PR cannot edit the predicate that judges it.
19
+ * - scripts/changeset/lint.cjs (changeset-lint, #5903) runs on pull_request
20
+ * and executes PR-tree code, so the base-checkout guarantee does not apply
21
+ * (a PR could already edit lint.cjs itself). The fork guard is what blocks
22
+ * forgery: sameRepo is derived from the GitHub event payload.
23
+ *
24
+ * What each caller exempts:
25
+ *
26
+ * - `Issue link required`: the closing-reference requirement.
27
+ * - `validate-title`: only the missing-issue-ref verdict; the title's type
28
+ * prefix (`bad-prefix`) is still enforced on these PRs.
29
+ * - changeset-lint: the fragment requirement; a malformed fragment or `pr:`
30
+ * drift still fails.
31
+ *
32
+ * Anti-forgery (#1389): a branch name alone is attacker-chosen — a fork can
33
+ * name its branch anything. The exemption therefore also requires
34
+ * `sameRepo === true` (head.repo.full_name == the base repository), which only
35
+ * someone with push access to this repository can satisfy, and the exact base
36
+ * branch each kind targets.
37
+ */
38
+
39
+ const { isReleaseVersion, SYNC_BRANCH_PREFIX } = require('./sync-next-version.cjs');
40
+
41
+ const RELEASE_PIPELINE_PR_KIND = Object.freeze({
42
+ RELEASE_MERGE: 'release-merge',
43
+ NEXT_VERSION_SYNC: 'next-version-sync',
44
+ MAIN_TO_NEXT_BACKMERGE: 'main-to-next-backmerge',
45
+ });
46
+
47
+ // release.yml validate-version: X.Y.0 cuts release/X.Y.0; X.Y.Z with Z>0 cuts
48
+ // hotfix/X.Y.Z. Same no-leading-zero grammar as its `grep -qE` checks.
49
+ const RELEASE_BRANCH_RE = /^release\/(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.0$/;
50
+ const HOTFIX_BRANCH_RE = /^hotfix\/(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.[1-9][0-9]*$/;
51
+
52
+ // auto-backmerge.yml: BR="chore/backmerge-main-to-next-${SHORT_SHA}" (#1389),
53
+ // SHORT_SHA=$(git rev-parse --short origin/main): 7 to 40 lowercase hex.
54
+ const BACKMERGE_BRANCH_PREFIX = 'chore/backmerge-main-to-next-';
55
+ const BACKMERGE_SHA_RE = /^[0-9a-f]{7,40}$/;
56
+
57
+ /**
58
+ * Classify a PR as one of the release pipeline's own PRs.
59
+ *
60
+ * @param {{ headRef?: unknown, baseRef?: unknown, sameRepo?: unknown }} [pr]
61
+ * @returns {string|null} a RELEASE_PIPELINE_PR_KIND value, or null when the PR
62
+ * is not a release-pipeline PR (including every fork PR).
63
+ */
64
+ function releasePipelinePrKind({ headRef, baseRef, sameRepo } = {}) {
65
+ if (sameRepo !== true) return null;
66
+ if (typeof headRef !== 'string' || typeof baseRef !== 'string') return null;
67
+
68
+ if (baseRef === 'main' && (RELEASE_BRANCH_RE.test(headRef) || HOTFIX_BRANCH_RE.test(headRef))) {
69
+ return RELEASE_PIPELINE_PR_KIND.RELEASE_MERGE;
70
+ }
71
+ if (baseRef === 'next' && headRef.startsWith(SYNC_BRANCH_PREFIX)
72
+ && isReleaseVersion(headRef.slice(SYNC_BRANCH_PREFIX.length))) {
73
+ return RELEASE_PIPELINE_PR_KIND.NEXT_VERSION_SYNC;
74
+ }
75
+ if (baseRef === 'next' && headRef.startsWith(BACKMERGE_BRANCH_PREFIX)
76
+ && BACKMERGE_SHA_RE.test(headRef.slice(BACKMERGE_BRANCH_PREFIX.length))) {
77
+ return RELEASE_PIPELINE_PR_KIND.MAIN_TO_NEXT_BACKMERGE;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** True iff the PR is one of the release pipeline's own PRs (see releasePipelinePrKind). */
83
+ function isReleasePipelinePr(pr) {
84
+ return releasePipelinePrKind(pr) !== null;
85
+ }
86
+
87
+ module.exports = {
88
+ RELEASE_PIPELINE_PR_KIND,
89
+ BACKMERGE_BRANCH_PREFIX,
90
+ releasePipelinePrKind,
91
+ isReleasePipelinePr,
92
+ };
@@ -37,10 +37,14 @@ const ISSUE_LINK_REASON = Object.freeze({
37
37
  FAIL_FILE_LIST_INCOMPLETE: 'fail_file_list_incomplete',
38
38
  });
39
39
 
40
- // #1389: backmerge PRs are opened by CI against `next`, never by a human or a
41
- // fork, so they are exempt from the issue-link requirement outright — but
42
- // ONLY when combined with `sameRepo === true` below (see header comment).
43
- const BACKMERGE_BRANCH_PREFIX = 'chore/backmerge-main-to-next-';
40
+ // #1389 / #5890: the release pipeline's own back-merge PRs (release/hotfix ->
41
+ // main, chore/sync-next-version-* and chore/backmerge-main-to-next-* -> next)
42
+ // are opened by CI, never by a human or a fork, and have no issue to link, so
43
+ // they are exempt outright. The predicate lives in release-pipeline-pr.cjs —
44
+ // shared with pr-title-validator.yml so the two gates agree — and requires
45
+ // `sameRepo === true` plus the exact base branch, so a fork cannot forge it
46
+ // by naming its branch.
47
+ const { releasePipelinePrKind, BACKMERGE_BRANCH_PREFIX } = require('./release-pipeline-pr.cjs');
44
48
 
45
49
  // #4196: Dependabot has no mechanism to link a PR it opens to a repo issue —
46
50
  // its alerts live in the Security tab, not as issues, so there is nothing
@@ -135,7 +139,7 @@ function allPathsAreTestsOrDocs(changedFiles) {
135
139
  });
136
140
  }
137
141
 
138
- function evaluateIssueLink({ prBody, headRef, sameRepo, authorLogin, changedFiles, changedFilesTotal }) {
142
+ function evaluateIssueLink({ prBody, headRef, baseRef, sameRepo, authorLogin, changedFiles, changedFilesTotal }) {
139
143
  if (authorLogin === DEPENDABOT_LOGIN) {
140
144
  return { ok: true, reason: ISSUE_LINK_REASON.OK_DEPENDABOT_EXEMPT };
141
145
  }
@@ -144,10 +148,14 @@ function evaluateIssueLink({ prBody, headRef, sameRepo, authorLogin, changedFile
144
148
  return { ok: true, reason: ISSUE_LINK_REASON.OK_CLOSING_KEYWORD };
145
149
  }
146
150
 
147
- // #1389: `sameRepo === true` is required, not just the branch-name prefix —
148
- // a fork PR could otherwise name its branch to forge this exemption.
149
- if (String(headRef || '').startsWith(BACKMERGE_BRANCH_PREFIX) && sameRepo === true) {
150
- return { ok: true, reason: ISSUE_LINK_REASON.OK_BACKMERGE_EXEMPT };
151
+ // #1389: `sameRepo === true` is required, not just the branch name — a fork
152
+ // PR could otherwise name its branch to forge this exemption (#5890 adds the
153
+ // base branch and the release/sync shapes; see release-pipeline-pr.cjs).
154
+ const releaseKind = releasePipelinePrKind({ headRef, baseRef, sameRepo });
155
+ if (releaseKind) {
156
+ // `kind` names which release-pipeline PR this is (the reason keeps its
157
+ // #1389 name so existing consumers of the enum are unchanged).
158
+ return { ok: true, reason: ISSUE_LINK_REASON.OK_BACKMERGE_EXEMPT, kind: releaseKind };
151
159
  }
152
160
 
153
161
  if (!hasFollowUpReference(prBody)) {
@@ -175,6 +183,7 @@ function main() {
175
183
  const result = evaluateIssueLink({
176
184
  prBody: process.env.PR_BODY || '',
177
185
  headRef: process.env.HEAD_REF || '',
186
+ baseRef: process.env.BASE_REF || '',
178
187
  sameRepo: process.env.SAME_REPO === 'true',
179
188
  authorLogin: process.env.PR_AUTHOR_LOGIN || '',
180
189
  changedFiles,
@@ -30,6 +30,15 @@ function isReleaseVersion(v) {
30
30
  return typeof v === 'string' && RELEASE_VERSION.test(v);
31
31
  }
32
32
 
33
+ // The branch syncViaPr opens. scripts/release-pipeline-pr.cjs exempts exactly
34
+ // this shape from the issue-link and PR-title gates (#5890).
35
+ const SYNC_BRANCH_PREFIX = 'chore/sync-next-version-';
36
+
37
+ /** The head branch of the sync PR for `version`. */
38
+ function syncBranchName(version) {
39
+ return `${SYNC_BRANCH_PREFIX}${version}`;
40
+ }
41
+
33
42
  /** Read the `version` field out of a package.json text blob. Throws on a missing/non-string version. */
34
43
  function versionFromPackageJson(text) {
35
44
  const parsed = JSON.parse(text);
@@ -76,7 +85,7 @@ function syncViaPr(version, { run = defaultRun } = {}) {
76
85
  process.stdout.write(`next already at ${version} — nothing to sync\n`);
77
86
  return 'noop';
78
87
  }
79
- const branch = `chore/sync-next-version-${version}`;
88
+ const branch = syncBranchName(version);
80
89
  run('git', ['checkout', '-B', branch, 'origin/next'], { timeout: 15000 });
81
90
  applyVersion(version, { run });
82
91
  run('git', ['add', '-u'], { timeout: 15000 });
@@ -162,4 +171,4 @@ if (require.main === module) {
162
171
  }
163
172
  }
164
173
 
165
- module.exports = { isReleaseVersion, versionFromPackageJson, applyVersion, syncViaPr, main, defaultRun };
174
+ module.exports = { isReleaseVersion, versionFromPackageJson, applyVersion, syncViaPr, main, defaultRun, SYNC_BRANCH_PREFIX, syncBranchName };
@@ -28,7 +28,7 @@ Brownfield equivalent of new-project. Project exists, PROJECT.md has history. Ga
28
28
  - `.planning/ROADMAP.md` — phase structure (continues numbering)
29
29
  - `.planning/STATE.md` — reset for new milestone
30
30
 
31
- **After:** `/gsd-plan-phase [N]` to start execution.
31
+ **After:** Run `/gsd-discuss-phase [N]` to clarify the approach (or `/gsd-plan-phase [N]` to plan directly); then `/gsd-execute-phase [N]` to execute.
32
32
  </objective>
33
33
 
34
34
  <execution_context>
@@ -36,7 +36,7 @@ Initialize a new project through unified flow: questioning → research (optiona
36
36
  - `.planning/ROADMAP.md` — phase structure
37
37
  - `.planning/STATE.md` — project memory
38
38
 
39
- **After this command:** Run `/gsd-plan-phase 1` to start execution.
39
+ **After this command:** Run `/gsd-discuss-phase 1` to clarify the approach (or `/gsd-plan-phase 1` to plan directly); then `/gsd-execute-phase 1` to execute.
40
40
  </objective>
41
41
 
42
42
  <execution_context>
@@ -2,7 +2,7 @@
2
2
  "name": "gsd-core-vscode",
3
3
  "displayName": "GSD Core",
4
4
  "description": "GSD orchestration engine embedded in VS Code (ADR-1239 IDE profile).",
5
- "version": "1.20.0",
5
+ "version": "1.20.1",
6
6
  "publisher": "opengsd",
7
7
  "engines": {
8
8
  "vscode": "^1.105.0"