@catladder/cli 0.0.0-main-a6b45abe → 0.0.0-main-fd9c5f51

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 (36) hide show
  1. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +48 -9
  2. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -1
  3. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.d.ts +83 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js +80 -0
  5. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js.map +1 -0
  6. package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js +4 -3
  7. package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js.map +1 -1
  8. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.d.ts +12 -7
  9. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +138 -31
  10. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
  11. package/dist/apps/cli/src/release/changesetsReleaseJob.js +64 -4
  12. package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
  13. package/dist/apps/cli/src/release/releaseGit.d.ts +2 -0
  14. package/dist/apps/cli/src/release/releaseGit.js +9 -1
  15. package/dist/apps/cli/src/release/releaseGit.js.map +1 -1
  16. package/dist/apps/cli/src/utils/github.d.ts +1 -1
  17. package/dist/apps/cli/src/utils/github.js.map +1 -1
  18. package/dist/bundles/catci/index.js +5 -5
  19. package/dist/bundles/catenv/index.js +5 -0
  20. package/dist/bundles/cli/index.js +282 -43
  21. package/dist/bundles/runner-images/changesets/Dockerfile +2 -1
  22. package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +15 -7
  23. package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js +5 -0
  24. package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js.map +1 -1
  25. package/dist/runner-images/changesets/Dockerfile +2 -1
  26. package/dist/skills/catladder-migrate-ci-backend/SKILL.md +15 -7
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +1 -1
  29. package/src/apps/cli/commands/project/__tests__/githubMergeGating.test.ts +123 -0
  30. package/src/apps/cli/commands/project/doctor/checkGithub.ts +74 -8
  31. package/src/apps/cli/commands/project/githubMergeGating.ts +101 -0
  32. package/src/apps/cli/commands/project/setup/index.ts +4 -3
  33. package/src/apps/cli/commands/project/setup/setupGithub.ts +190 -27
  34. package/src/release/changesetsReleaseJob.ts +81 -6
  35. package/src/release/releaseGit.ts +11 -0
  36. package/src/utils/github.ts +1 -1
@@ -6,7 +6,8 @@
6
6
  * pushes — the pushed tag triggers the taggedRelease pipeline.
7
7
  */
8
8
  import { existsSync } from "fs";
9
- import { readFile, readdir, rm, writeFile } from "fs/promises";
9
+ import { mkdtemp, readFile, readdir, rm, writeFile } from "fs/promises";
10
+ import { tmpdir } from "os";
10
11
  import { join } from "path";
11
12
  import type { Changeset } from "./changesets";
12
13
  import {
@@ -16,7 +17,12 @@ import {
16
17
  prependToChangelog,
17
18
  renderChangelogEntries,
18
19
  } from "./changesets";
19
- import { ensureReleaseHistory, getLastReleaseTag, git } from "./releaseGit";
20
+ import {
21
+ ensureReleaseHistory,
22
+ getLastReleaseTag,
23
+ git,
24
+ gitWithEnv,
25
+ } from "./releaseGit";
20
26
  import { appendStepSummary } from "./stepSummary";
21
27
 
22
28
  const CHANGESET_DIR = ".changeset";
@@ -75,8 +81,12 @@ const requireEnv = (name: string): string => {
75
81
  * - gitlab: CI checkouts have a read-only origin, so push via an
76
82
  * authenticated url with GL_TOKEN (the same project access token
77
83
  * semantic-release uses)
78
- * - github: actions/checkout persists the job token credentials, so a
79
- * plain push to origin works (`permissions: contents: write`)
84
+ * - github: over ssh with the release deploy key
85
+ * (CATLADDER_RELEASE_KEY, provisioned by `project setup`) — merge
86
+ * gating requires the `catladder ✅` check on the default branch,
87
+ * and only a deploy key can bypass that (github deliberately never
88
+ * lets the workflow token bypass rulesets). Without the secret the
89
+ * push falls back to origin, which works on repos without gating.
80
90
  */
81
91
  const pushCommitAndTag = async (tag: string) => {
82
92
  // --atomic: all refs or none — a rejected tag must not leave the
@@ -84,7 +94,29 @@ const pushCommitAndTag = async (tag: string) => {
84
94
  // rederived an existing version)
85
95
  if (process.env.GITHUB_ACTIONS === "true") {
86
96
  const branch = requireEnv("GITHUB_REF_NAME");
87
- await git("push", "--atomic", "origin", `HEAD:refs/heads/${branch}`, tag);
97
+ const refs = [`HEAD:refs/heads/${branch}`, tag];
98
+ const deployKey = process.env.CATLADDER_RELEASE_KEY;
99
+ if (deployKey) {
100
+ await pushWithDeployKey(deployKey, refs);
101
+ return;
102
+ }
103
+ try {
104
+ await git("push", "--atomic", "origin", ...refs);
105
+ } catch (e) {
106
+ // GH006: a required status check rejects the fresh release
107
+ // commit (it cannot have a passing check yet) — the push needs
108
+ // the release deploy key, which `project setup` provisions
109
+ if (/GH006|protected branch/i.test(`${e?.message ?? e}`)) {
110
+ throw new Error(
111
+ `pushing the release to '${branch}' was rejected by its branch protection:\n` +
112
+ `${e.message}\n` +
113
+ `The workflow token cannot bypass a required status check on '${branch}'. ` +
114
+ `Run \`catladder project setup\` to provision the release deploy key ` +
115
+ `(${RELEASE_KEY_ENV} secret + ruleset bypass), then rerun this job.`,
116
+ );
117
+ }
118
+ throw e;
119
+ }
88
120
  return;
89
121
  }
90
122
  const token = requireEnv("GL_TOKEN");
@@ -95,6 +127,41 @@ const pushCommitAndTag = async (tag: string) => {
95
127
  await git("push", "--atomic", remote, `HEAD:refs/heads/${branch}`, tag);
96
128
  };
97
129
 
130
+ const RELEASE_KEY_ENV = "CATLADDER_RELEASE_KEY";
131
+
132
+ /**
133
+ * pushes over ssh with the release deploy key — the only actor that
134
+ * can bypass the merge-gating ruleset on the default branch
135
+ */
136
+ const pushWithDeployKey = async (privateKey: string, refs: string[]) => {
137
+ const serverUrl = process.env.GITHUB_SERVER_URL ?? "https://github.com";
138
+ const host = new URL(serverUrl).host;
139
+ const repository = requireEnv("GITHUB_REPOSITORY");
140
+ const dir = await mkdtemp(join(tmpdir(), "release-key-"));
141
+ try {
142
+ const keyFile = join(dir, "id_ed25519");
143
+ // a key file without a trailing newline is rejected by openssh
144
+ await writeFile(
145
+ keyFile,
146
+ privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`,
147
+ {
148
+ mode: 0o600,
149
+ },
150
+ );
151
+ await gitWithEnv(
152
+ {
153
+ GIT_SSH_COMMAND: `ssh -i ${keyFile} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new`,
154
+ },
155
+ "push",
156
+ "--atomic",
157
+ `git@${host}:${repository}.git`,
158
+ ...refs,
159
+ );
160
+ } finally {
161
+ await rm(dir, { recursive: true, force: true });
162
+ }
163
+ };
164
+
98
165
  /**
99
166
  * tags pushed with the default GITHUB_TOKEN do not trigger `on: push:
100
167
  * tags` workflows (github's recursion guard), so the taggedRelease
@@ -196,7 +263,15 @@ export const changesetsReleaseJob = async () => {
196
263
  } else {
197
264
  await git("add", CHANGELOG_FILE, CHANGESET_DIR);
198
265
  }
199
- await git("commit", "-m", `chore(release): ${version}\n\n${entries}`);
266
+ // [skip ci] on github only: deploy-key pushes trigger workflows
267
+ // (unlike GITHUB_TOKEN pushes), and neither the release commit nor
268
+ // the tag needs a push-triggered run — the taggedRelease workflow is
269
+ // dispatched explicitly below, [skip ci] keeps it the only run. On
270
+ // gitlab the tag pipeline MUST fire natively, so no marker there
271
+ // (release-commit branch pipelines are already filtered by rules).
272
+ const onGithub = process.env.GITHUB_ACTIONS === "true";
273
+ const subject = `chore(release): ${version}${onGithub ? " [skip ci]" : ""}`;
274
+ await git("commit", "-m", `${subject}\n\n${entries}`);
200
275
  await git("tag", tag);
201
276
  await pushCommitAndTag(tag);
202
277
  console.log(`released ${tag}`);
@@ -12,6 +12,17 @@ export const git = async (...args: string[]): Promise<string> => {
12
12
  return stdout.trim();
13
13
  };
14
14
 
15
+ /** like `git`, but with extra environment variables (e.g. GIT_SSH_COMMAND) */
16
+ export const gitWithEnv = async (
17
+ env: Record<string, string>,
18
+ ...args: string[]
19
+ ): Promise<string> => {
20
+ const { stdout } = await execFile("git", args, {
21
+ env: { ...process.env, ...env },
22
+ });
23
+ return stdout.trim();
24
+ };
25
+
15
26
  /**
16
27
  * CI checkouts are usually shallow and tagless (gitlab clones with
17
28
  * depth 1; github's checkout needs fetch-depth: 0) — the local history
@@ -170,7 +170,7 @@ export const ensureGithubEnvironment = async (
170
170
 
171
171
  /** runs `gh api` with an optional JSON body and returns the parsed response */
172
172
  export const ghApiJson = async <T = any>(
173
- method: "GET" | "PATCH" | "PUT",
173
+ method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
174
174
  path: string,
175
175
  body?: unknown,
176
176
  ): Promise<T> => {