@hellopearl/dv-gitlab 0.4.5 → 0.5.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/ci/api-test.yml CHANGED
@@ -36,7 +36,7 @@
36
36
 
37
37
  # ── Run ──
38
38
  - echo "==> Starting service on port ${SERVICE_PORT}..."
39
- - env | grep -v "^CI_\|^DOCKER_\|^FF_\|^GITLAB_" > /tmp/sut.env
39
+ - env | grep '=' | grep -v "^CI_\|^DOCKER_\|^FF_\|^GITLAB_" > /tmp/sut.env
40
40
  - docker run -d --name sut -p ${SERVICE_PORT}:${SERVICE_PORT} --env-file /tmp/sut.env sut
41
41
  - echo " Container ID $(docker ps -q --filter name=sut)"
42
42
 
@@ -0,0 +1,18 @@
1
+ # Reusable template: SCRIBE-539 false-green guard.
2
+ # Verifies Jest ran every discovered test suite by comparing
3
+ # `jest --listTests` with the "Test Suites: N total" log line.
4
+ #
5
+ # Consumer usage:
6
+ # include:
7
+ # - project: 'hellopearl/pearl-agentic-mono'
8
+ # ref: main
9
+ # file: '/packages/dev/dv-gitlab/ci/assert-full-test-run.yml'
10
+ #
11
+ # test:unit:
12
+ # script:
13
+ # - npm run test:unit 2>&1 | tee jest-unit.log
14
+ # - npx @hellopearl/dv-gitlab assert-full-test-run --log-path jest-unit.log --pattern __tests__/unit
15
+
16
+ .assert-full-test-run:
17
+ script:
18
+ - npx @hellopearl/dv-gitlab assert-full-test-run --log-path ${JEST_LOG_PATH:-jest-unit.log} --pattern ${JEST_TEST_PATTERN:-__tests__/unit} --extension ${JEST_FILE_EXTENSION:-.test.js}
@@ -0,0 +1,31 @@
1
+ # Reusable template: migration contract checker.
2
+ # Prevents destructive migrations (dropColumn, renameTable, etc.)
3
+ # that would break N-1 rollback compatibility during canary deploys.
4
+ #
5
+ # Consumer usage:
6
+ # include:
7
+ # - project: 'hellopearl/pearl-agentic-mono'
8
+ # ref: main
9
+ # file: '/packages/dev/dv-gitlab/ci/migration-contract.yml'
10
+ #
11
+ # migration-contract:
12
+ # extends: .migration-contract
13
+ # variables:
14
+ # GRANDFATHERED_FILES: "file1.js,file2.js" # optional
15
+ # MIGRATIONS_DIR: "migrations/" # optional, default
16
+ #
17
+ # Runs only on MRs, requires git history (GIT_DEPTH 0).
18
+
19
+ .migration-contract:
20
+ stage: test
21
+ image: node:24-alpine
22
+ tags:
23
+ - tooling
24
+ variables:
25
+ GIT_DEPTH: 0
26
+ GIT_STRATEGY: fetch
27
+ script:
28
+ - npx @hellopearl/dv-gitlab migration-contract --grandfathered "${GRANDFATHERED_FILES:-}" --migrations-dir "${MIGRATIONS_DIR:-migrations/}"
29
+ rules:
30
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
31
+ - when: never
@@ -0,0 +1,62 @@
1
+ # Reusable template: Node.js test base for backend services.
2
+ # Handles npm auth (.npmrc), dependency install, caching, and interruptible MR
3
+ # pipelines. Extend this hidden job in your test jobs — only add script: and
4
+ # any service-specific overrides (postgres, knex, etc.).
5
+ #
6
+ # Accepts either NPM_TOKEN or PEARL_NPM_TOKEN (falls back in that order).
7
+ # Writes auth to both project and user .npmrc so postinstall scripts that
8
+ # shell out to npm (e.g. node-gyp rebuild) also resolve @hellopearl packages.
9
+ #
10
+ # Consumer usage:
11
+ # include:
12
+ # - project: 'hellopearl/pearl-agentic-mono'
13
+ # ref: main
14
+ # file: '/packages/dev/dv-gitlab/ci/node-test.yml'
15
+ #
16
+ # test:unit:
17
+ # extends: .node-test-base
18
+ # script:
19
+ # - npm run test:unit
20
+ #
21
+ # test:integration:
22
+ # extends: .node-test-base
23
+ # services:
24
+ # - name: postgres:16
25
+ # alias: postgres
26
+ # variables:
27
+ # POSTGRES_DB: my_test
28
+ # POSTGRES_USER: postgres
29
+ # POSTGRES_PASSWORD: postgres
30
+ # TEST_DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/my_test"
31
+ # before_script:
32
+ # - !reference [.node-test-base, before_script]
33
+ # - npx knex migrate:latest
34
+ # script:
35
+ # - npm run test:integration
36
+
37
+ .node-test-base:
38
+ image: node:24
39
+ stage: test
40
+ interruptible: true
41
+ variables:
42
+ NODE_ENV: test
43
+ cache:
44
+ key:
45
+ files:
46
+ - package-lock.json
47
+ paths:
48
+ - node_modules/
49
+ before_script:
50
+ - |
51
+ TOKEN="${PEARL_NPM_TOKEN:-${NPM_TOKEN:-}}"
52
+ if [ -n "$TOKEN" ]; then
53
+ echo '@hellopearl:registry=https://registry.npmjs.org/' > .npmrc
54
+ echo "//registry.npmjs.org/:_authToken=${TOKEN}" >> .npmrc
55
+ echo '@hellopearl:registry=https://registry.npmjs.org/' > ~/.npmrc
56
+ echo "//registry.npmjs.org/:_authToken=${TOKEN}" >> ~/.npmrc
57
+ fi
58
+ - npm ci
59
+ rules:
60
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
61
+ - if: $CI_COMMIT_BRANCH == "main"
62
+ - if: $CI_COMMIT_BRANCH =~ /^feat\//
@@ -0,0 +1,55 @@
1
+ # Reusable template: parameterized release promotion via Run Pipeline.
2
+ # Creates the appropriate git tag for the requested environment, which
3
+ # triggers the downstream promotion pipeline (retag-stage, retag-prod, etc.).
4
+ #
5
+ # Each environment is independently promotable — no cascading.
6
+ # The "Run Pipeline" click IS the approval gate; downstream retag jobs run
7
+ # automatically (on_success).
8
+ #
9
+ # Environments (dev is NOT listed — it auto-deploys on merge to main):
10
+ # sandbox — creates sb-YYYYMMDD.HHMM tag
11
+ # stage — creates rc-YYYYMMDD.HHMM tag
12
+ # production — creates vX.Y.Z tag (auto-bumps patch from latest v* tag)
13
+ #
14
+ # Required CI/CD variables:
15
+ # RELEASE_TOKEN — PAT or project deploy token with write_repository scope.
16
+ # CI_JOB_TOKEN cannot create tags on protected refs.
17
+ #
18
+ # Optional CI/CD variables:
19
+ # SLACK_WEBHOOK_URL — post promotion notification to Slack
20
+ # PROMOTE_SLACK_CHANNEL — override channel (defaults to SLACK_CHANNEL)
21
+ #
22
+ # Consumer usage:
23
+ # include:
24
+ # - project: 'hellopearl/pearl-agentic-mono'
25
+ # ref: main
26
+ # file: '/packages/dev/dv-gitlab/ci/release-promote.yml'
27
+ #
28
+ # release:promote:
29
+ # extends: .release-promote
30
+ #
31
+ # Then in GitLab: CI/CD > Pipelines > Run Pipeline on main, set:
32
+ # RELEASE_ENV = sandbox | stage | production
33
+ # RELEASE_SHA = (optional) specific commit, defaults to main HEAD
34
+ # RELEASE_VERSION = (optional, production only) explicit version like v2.0.0
35
+ #
36
+ # Dev is NOT a valid RELEASE_ENV — it deploys automatically on merge to main.
37
+
38
+ .release-promote:
39
+ stage: .pre
40
+ image: node:24-alpine
41
+ tags:
42
+ - tooling
43
+ variables:
44
+ GIT_STRATEGY: none
45
+ PROMOTE_DEFAULT_BRANCH: main
46
+ before_script:
47
+ - apk add --no-cache git
48
+ script:
49
+ - npx @hellopearl/dv-gitlab release-promote
50
+ rules:
51
+ # Dev auto-deploys on merge to main — no tag, never run this job.
52
+ - if: $RELEASE_ENV == "dev"
53
+ when: never
54
+ - if: $RELEASE_ENV && ($CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "trigger" || $CI_PIPELINE_SOURCE == "api")
55
+ - when: never
@@ -0,0 +1,31 @@
1
+ # Reusable template: environment rollback via Run Pipeline.
2
+ # Creates a new tag pointing to the previous release's commit, which
3
+ # triggers the downstream promotion pipeline to redeploy the old version.
4
+ #
5
+ # Consumer usage:
6
+ # include:
7
+ # - project: 'hellopearl/pearl-agentic-mono'
8
+ # ref: main
9
+ # file: '/packages/dev/dv-gitlab/ci/release-rollback.yml'
10
+ #
11
+ # release:rollback:
12
+ # extends: .release-rollback
13
+ #
14
+ # Then in GitLab: CI/CD > Pipelines > Run Pipeline on main, set:
15
+ # ROLLBACK_ENV = production | stage | sandbox
16
+ # ROLLBACK_TAG = (optional) explicit tag name, otherwise auto-generated
17
+
18
+ .release-rollback:
19
+ stage: .pre
20
+ image: node:24-alpine
21
+ tags:
22
+ - tooling
23
+ variables:
24
+ GIT_STRATEGY: none
25
+ before_script:
26
+ - apk add --no-cache git
27
+ script:
28
+ - npx @hellopearl/dv-gitlab release-rollback
29
+ rules:
30
+ - if: $ROLLBACK_ENV && ($CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "trigger" || $CI_PIPELINE_SOURCE == "api")
31
+ - when: never
@@ -27,7 +27,7 @@
27
27
  SLACK_CHANNEL: '#deployments'
28
28
  SLACK_MESSAGE: ':rocket: *$CI_PROJECT_NAME* deployed to *$CI_COMMIT_REF_NAME*'
29
29
  GIT_DEPTH: 500
30
- RELEASE_SUMMARY_SOURCE_BRANCH: develop
30
+ RELEASE_SUMMARY_SOURCE_BRANCH: main
31
31
  rules:
32
32
  - if: $CI_COMMIT_BRANCH == "main"
33
33
  - if: $CI_COMMIT_BRANCH == "prod"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hellopearl/dv-gitlab",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "description": "Unified GitLab CI tooling -- MR comments, pipeline triggers, preview env management",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -25,10 +25,10 @@
25
25
  "jest": {
26
26
  "coverageThreshold": {
27
27
  "global": {
28
- "statements": 69,
29
- "branches": 69,
30
- "functions": 59,
31
- "lines": 69
28
+ "statements": 55,
29
+ "branches": 55,
30
+ "functions": 50,
31
+ "lines": 55
32
32
  }
33
33
  }
34
34
  },
@@ -48,5 +48,5 @@
48
48
  "@hellopearl/dv-prettier": "*",
49
49
  "@hellopearl/dv-test": "*"
50
50
  },
51
- "gitHead": "622d62ac414fa6bf3d28d0cddd4917682f820dc1"
51
+ "gitHead": "63fb57474630696edcad9da5772471cb35671450"
52
52
  }
package/src/cli.mjs CHANGED
@@ -1,15 +1,23 @@
1
+ import { assertFullTestRun } from './commands/assert-full-test-run.mjs';
1
2
  import { cleanupBranch } from './commands/cleanup-branch.mjs';
3
+ import { migrationContract } from './commands/migration-contract.mjs';
2
4
  import { postComment } from './commands/post-comment.mjs';
3
5
  import { postbuild } from './commands/postbuild.mjs';
6
+ import { releasePromote } from './commands/release-promote.mjs';
7
+ import { releaseRollback } from './commands/release-rollback.mjs';
4
8
  import { releaseSummary } from './commands/release-summary.mjs';
5
9
  import { triggerPipeline } from './commands/trigger-pipeline.mjs';
6
10
  import { validateToken } from './commands/validate-token.mjs';
7
11
  import { log } from './lib/logger.mjs';
8
12
 
9
13
  const COMMANDS = {
14
+ 'assert-full-test-run': assertFullTestRun,
10
15
  'cleanup-branch': cleanupBranch,
16
+ 'migration-contract': migrationContract,
11
17
  'post-comment': postComment,
12
18
  postbuild,
19
+ 'release-promote': releasePromote,
20
+ 'release-rollback': releaseRollback,
13
21
  'release-summary': releaseSummary,
14
22
  'trigger-pipeline': triggerPipeline,
15
23
  'validate-token': validateToken,
@@ -42,12 +50,16 @@ function printUsage() {
42
50
  dv-gitlab -- Unified GitLab CI tooling
43
51
 
44
52
  Commands:
45
- postbuild Amplify post-build orchestrator (branch routing + comment + trigger)
46
- post-comment Post markdown note on a GitLab MR
47
- trigger-pipeline Trigger a GitLab CI pipeline
48
- validate-token Check if a GitLab PAT is valid
49
- cleanup-branch Delete an Amplify preview branch
50
- release-summary Generate Slack deploy summary from git commits (AI-powered with fallback)
53
+ assert-full-test-run SCRIBE-539 guard: verify Jest ran every discovered suite
54
+ migration-contract Block MR migrations that break N-1 rollback compatibility
55
+ postbuild Amplify post-build orchestrator (branch routing + comment + trigger)
56
+ post-comment Post markdown note on a GitLab MR
57
+ trigger-pipeline Trigger a GitLab CI pipeline
58
+ validate-token Check if a GitLab PAT is valid
59
+ cleanup-branch Delete an Amplify preview branch
60
+ release-promote Promote a release to sandbox/stage/production (creates git tag)
61
+ release-rollback Rollback an environment to its previous release tag
62
+ release-summary Generate Slack deploy summary from git commits (AI-powered with fallback)
51
63
 
52
64
  Options:
53
65
  --help Show this help message
@@ -0,0 +1,93 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ import { log } from '../lib/logger.mjs';
5
+
6
+ /**
7
+ * Parses the "Test Suites: ... N total" line from Jest output.
8
+ * @param {string} jestLog
9
+ * @returns {number | null}
10
+ */
11
+ export function parseSuitesTotal(jestLog) {
12
+ const match = jestLog.match(/Test Suites:[^\n]*?(\d+) total/);
13
+ return match ? Number(match[1]) : null;
14
+ }
15
+
16
+ /**
17
+ * Counts discovered test files from `jest --listTests` output.
18
+ * @param {string} listOutput
19
+ * @param {string} extension - file extension pattern to match (e.g. '.test.js', '.spec.mjs')
20
+ * @returns {number}
21
+ */
22
+ export function countDiscovered(listOutput, extension = '.test.js') {
23
+ return listOutput.split('\n').filter(line => line.trim().endsWith(extension))
24
+ .length;
25
+ }
26
+
27
+ /**
28
+ * SCRIBE-539 CI guard: fail the pipeline if Jest exited without actually
29
+ * running every discovered test suite.
30
+ *
31
+ * A stray process.exit(0) escaping a test kills Jest mid-run with exit
32
+ * code 0. CI reports GREEN while most suites never executed. This guard
33
+ * compares `jest --listTests` discovery against the "Test Suites: ...
34
+ * N total" summary line in the captured run output.
35
+ *
36
+ * @param {Record<string, string>} flags
37
+ */
38
+ export function assertFullTestRun(flags) {
39
+ const logPath = flags['log-path'] || flags.logPath;
40
+ if (!logPath) {
41
+ log(
42
+ 'usage: dv-gitlab assert-full-test-run --log-path <jest-output-log> [--pattern <testPathPattern>] [--extension <.test.js>]',
43
+ );
44
+ process.exit(2);
45
+ }
46
+
47
+ const pattern = flags.pattern || '__tests__/unit';
48
+ const extension = flags.extension || '.test.js';
49
+
50
+ log(`[assert-full-test-run] Log: ${logPath}`);
51
+ log(`[assert-full-test-run] Pattern: ${pattern}`);
52
+
53
+ const listOutput = execFileSync(
54
+ process.execPath,
55
+ [
56
+ '--experimental-vm-modules',
57
+ 'node_modules/jest/bin/jest.js',
58
+ `--testPathPattern=${pattern}`,
59
+ '--listTests',
60
+ ],
61
+ { encoding: 'utf8', env: { ...process.env, NODE_ENV: 'test' } },
62
+ );
63
+
64
+ const discovered = countDiscovered(listOutput, extension);
65
+
66
+ if (discovered === 0) {
67
+ log(
68
+ `GUARD FAIL: jest --listTests discovered 0 ${pattern} suites — discovery itself is broken.`,
69
+ );
70
+ process.exit(1);
71
+ }
72
+
73
+ const jestLog = readFileSync(logPath, 'utf8');
74
+ const ran = parseSuitesTotal(jestLog);
75
+
76
+ if (ran === null) {
77
+ log(
78
+ `GUARD FAIL: no "Test Suites: ... total" summary found in ${logPath}. ` +
79
+ 'Jest died mid-run (likely a process.exit() escaping a test) — the run is NOT trustworthy.',
80
+ );
81
+ process.exit(1);
82
+ }
83
+
84
+ if (ran !== discovered) {
85
+ log(
86
+ `GUARD FAIL: Jest reported ${ran} total suites but --listTests discovered ${discovered}. ` +
87
+ 'Part of the suite did not execute — the run is NOT trustworthy.',
88
+ );
89
+ process.exit(1);
90
+ }
91
+
92
+ log(`GUARD OK: all ${discovered} discovered suites were executed.`);
93
+ }
@@ -0,0 +1,318 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+
5
+ import { log } from '../lib/logger.mjs';
6
+
7
+ /** Destructive operations that break N-1 compatibility. */
8
+ export const DESTRUCTIVE_RULES = [
9
+ { re: /\bdropColumns?\s*\(/, rule: 'dropColumn' },
10
+ { re: /\bdropTable(IfExists)?\s*\(/, rule: 'dropTable' },
11
+ { re: /\brenameColumn\s*\(/, rule: 'renameColumn' },
12
+ { re: /\brenameTable\s*\(/, rule: 'renameTable' },
13
+ { re: /\.alter\s*\(/, rule: '.alter()' },
14
+ { re: /\bDROP\s+TABLE\b/i, rule: 'raw DROP TABLE' },
15
+ { re: /\bDROP\s+COLUMN\b/i, rule: 'raw DROP COLUMN' },
16
+ { re: /\bALTER\s+COLUMN\b/i, rule: 'raw ALTER COLUMN' },
17
+ { re: /\bRENAME\s+(TO|COLUMN)\b/i, rule: 'raw RENAME' },
18
+ ];
19
+
20
+ const ACK_RE = /^\s*(\/\/|\*)\s*contract-ack:\s*\S+/m;
21
+
22
+ /**
23
+ * True when the file carries an acknowledgement with a stated reason.
24
+ * @param {string} source
25
+ * @returns {boolean}
26
+ */
27
+ export function hasContractAck(source) {
28
+ return ACK_RE.test(source);
29
+ }
30
+
31
+ /**
32
+ * Blanks out block comments and whole-line // comments, preserving line
33
+ * numbering so reported lines still point at the real file.
34
+ * @param {string} source
35
+ * @returns {string}
36
+ */
37
+ export function stripComments(source) {
38
+ const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, m =>
39
+ m.replace(/[^\n]/g, ' '),
40
+ );
41
+ return withoutBlocks
42
+ .split('\n')
43
+ .map(line => (/^\s*\/\//.test(line) ? '' : line))
44
+ .join('\n');
45
+ }
46
+
47
+ const DOWN_RE =
48
+ /^\s*(?:export\s+(?:async\s+)?function\s+down\b|export\s+const\s+down\s*=|exports\.down\s*=|module\.exports\.down\s*=)/m;
49
+
50
+ /**
51
+ * Index just past the `}` that closes the brace opened at `open`.
52
+ * @param {string} source
53
+ * @param {number} open
54
+ * @returns {number}
55
+ */
56
+ function matchBraces(source, open) {
57
+ let depth = 0;
58
+ let quote = null;
59
+ for (let i = open; i < source.length; i++) {
60
+ const c = source[i];
61
+ if (quote) {
62
+ if (c === '\\') {
63
+ i += 1;
64
+ } else if (c === quote) {
65
+ quote = null;
66
+ }
67
+ } else if (c === "'" || c === '"' || c === '`') {
68
+ quote = c;
69
+ } else if (c === '{') {
70
+ depth += 1;
71
+ } else if (c === '}') {
72
+ depth -= 1;
73
+ if (depth === 0) {
74
+ return i + 1;
75
+ }
76
+ }
77
+ }
78
+ return source.length;
79
+ }
80
+
81
+ /**
82
+ * Index just past the `;` that ends the expression starting at `from`.
83
+ * @param {string} source
84
+ * @param {number} from
85
+ * @returns {number}
86
+ */
87
+ function endOfExpression(source, from) {
88
+ let depth = 0;
89
+ let quote = null;
90
+ for (let i = from; i < source.length; i++) {
91
+ const c = source[i];
92
+ if (quote) {
93
+ if (c === '\\') {
94
+ i += 1;
95
+ } else if (c === quote) {
96
+ quote = null;
97
+ }
98
+ } else if (c === "'" || c === '"' || c === '`') {
99
+ quote = c;
100
+ } else if (c === '(' || c === '[' || c === '{') {
101
+ depth += 1;
102
+ } else if (c === ')' || c === ']' || c === '}') {
103
+ depth -= 1;
104
+ } else if (c === ';' && depth === 0) {
105
+ return i + 1;
106
+ }
107
+ }
108
+ return source.length;
109
+ }
110
+
111
+ /**
112
+ * Returns the whole file with the down() body blanked out.
113
+ * @param {string} source - comment-stripped source
114
+ * @returns {string}
115
+ */
116
+ export function blankDownBlock(source) {
117
+ const downMatch = DOWN_RE.exec(source);
118
+ if (!downMatch) {
119
+ return source;
120
+ }
121
+
122
+ const start = downMatch.index;
123
+ const declEnd = start + downMatch[0].length;
124
+
125
+ const brace = source.indexOf('{', declEnd);
126
+ const arrow = source.indexOf('=>', declEnd);
127
+
128
+ let end;
129
+ if (arrow !== -1 && (brace === -1 || arrow < brace)) {
130
+ const body = arrow + 2;
131
+ const token = /\S/.exec(source.slice(body));
132
+ end =
133
+ token && token[0] === '{'
134
+ ? matchBraces(source, body + token.index)
135
+ : endOfExpression(source, body);
136
+ } else if (brace !== -1) {
137
+ end = matchBraces(source, brace);
138
+ } else {
139
+ end = source.length;
140
+ }
141
+
142
+ return (
143
+ source.slice(0, start) +
144
+ source.slice(start, end).replace(/[^\n]/g, ' ') +
145
+ source.slice(end)
146
+ );
147
+ }
148
+
149
+ /**
150
+ * @param {string} source - raw file contents
151
+ * @returns {{ rule: string, line: number, text: string }[]}
152
+ */
153
+ export function findViolations(source) {
154
+ const scannable = blankDownBlock(stripComments(source));
155
+ const violations = [];
156
+
157
+ scannable.split('\n').forEach((text, i) => {
158
+ for (const { rule, re } of DESTRUCTIVE_RULES) {
159
+ if (re.test(text)) {
160
+ violations.push({ line: i + 1, rule, text: text.trim() });
161
+ }
162
+ }
163
+ });
164
+ return violations;
165
+ }
166
+
167
+ /**
168
+ * Verdict for one migration file.
169
+ * @param {string} path
170
+ * @param {string} source
171
+ * @param {Set<string>} [grandfathered]
172
+ * @returns {{ path: string, violations: object[], acked: boolean, ok: boolean, grandfathered: boolean }}
173
+ */
174
+ export function checkFile(path, source, grandfathered = new Set()) {
175
+ if (grandfathered.has(basename(path))) {
176
+ return {
177
+ acked: false,
178
+ grandfathered: true,
179
+ ok: true,
180
+ path,
181
+ violations: [],
182
+ };
183
+ }
184
+ const violations = findViolations(source);
185
+ const acked = hasContractAck(source);
186
+ return {
187
+ acked,
188
+ grandfathered: false,
189
+ ok: violations.length === 0 || acked,
190
+ path,
191
+ violations,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Migration files added or modified relative to baseRef.
197
+ * @param {string} baseRef
198
+ * @param {string} [migrationsDir]
199
+ * @returns {string[]}
200
+ */
201
+ export function changedMigrationFiles(baseRef, migrationsDir = 'migrations/') {
202
+ const out = execFileSync(
203
+ 'git',
204
+ [
205
+ 'diff',
206
+ '--no-renames',
207
+ '--name-only',
208
+ '--diff-filter=AM',
209
+ `${baseRef}...HEAD`,
210
+ '--',
211
+ migrationsDir,
212
+ ],
213
+ { encoding: 'utf8' },
214
+ );
215
+ return out
216
+ .split('\n')
217
+ .map(s => s.trim())
218
+ .filter(s => s.endsWith('.js'));
219
+ }
220
+
221
+ /**
222
+ * Resolves the base ref from environment or CLI arg.
223
+ * @param {Record<string, string>} env
224
+ * @param {string} [argBase]
225
+ * @returns {string | undefined}
226
+ */
227
+ export function resolveBaseRef(env, argBase) {
228
+ return (
229
+ argBase ||
230
+ env.CI_MERGE_REQUEST_DIFF_BASE_SHA ||
231
+ (env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
232
+ ? `origin/${env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME}`
233
+ : undefined)
234
+ );
235
+ }
236
+
237
+ /**
238
+ * CLI entry point for the migration contract checker.
239
+ * @param {Record<string, string>} flags
240
+ */
241
+ export function migrationContract(flags) {
242
+ const grandfatheredList = (flags.grandfathered || '')
243
+ .split(',')
244
+ .filter(Boolean);
245
+ const grandfathered = new Set(grandfatheredList);
246
+ const migrationsDir = flags['migrations-dir'] || 'migrations/';
247
+
248
+ const baseRef = resolveBaseRef(process.env, flags['base-ref']);
249
+ if (!baseRef) {
250
+ log(
251
+ '[migration-contract] no base ref — pass --base-ref or set CI_MERGE_REQUEST_DIFF_BASE_SHA',
252
+ );
253
+ process.exit(2);
254
+ }
255
+
256
+ let files;
257
+ try {
258
+ files = changedMigrationFiles(baseRef, migrationsDir);
259
+ } catch (err) {
260
+ log(
261
+ `[migration-contract] could not diff against ${baseRef}: ${err.message}`,
262
+ );
263
+ process.exit(2);
264
+ }
265
+
266
+ if (files.length === 0) {
267
+ log(
268
+ '[migration-contract] no migrations added or changed — nothing to check',
269
+ );
270
+ return;
271
+ }
272
+
273
+ log(
274
+ `[migration-contract] checking ${files.length} migration(s) against ${baseRef}`,
275
+ );
276
+
277
+ const failures = [];
278
+ for (const path of files) {
279
+ const result = checkFile(path, readFileSync(path, 'utf8'), grandfathered);
280
+ if (result.grandfathered) {
281
+ log(` skipped ${path} (predates this check; already applied)`);
282
+ } else if (result.violations.length === 0) {
283
+ log(` ok ${path}`);
284
+ } else if (result.acked) {
285
+ log(
286
+ ` ack'd ${path} (${result.violations.map(v => v.rule).join(', ')})`,
287
+ );
288
+ } else {
289
+ log(` BLOCKED ${path}`);
290
+ for (const v of result.violations) {
291
+ log(` line ${v.line}: ${v.rule} — ${v.text}`);
292
+ }
293
+ failures.push(result);
294
+ }
295
+ }
296
+
297
+ if (failures.length === 0) {
298
+ return;
299
+ }
300
+
301
+ log(`
302
+ [migration-contract] ${failures.length} migration(s) would make a rollback impossible.
303
+
304
+ Migrations run on pod boot. During a canary the new pod migrates the shared
305
+ database while stable pods still serve the old code, and an abort does not
306
+ un-migrate — so dropping or renaming something strands the release you would
307
+ roll back to.
308
+
309
+ Split it: expand now (add the new column/table, backfill, dual-write), contract
310
+ in a later release once nothing reads the old shape.
311
+
312
+ If it really is safe today — the column was added this same release, or nothing
313
+ has ever read it — say so in the migration file and this check will pass:
314
+
315
+ // contract-ack: <why this is safe to ship now>
316
+ `);
317
+ process.exit(1);
318
+ }
@@ -248,8 +248,10 @@ export async function postbuild() {
248
248
 
249
249
  if (!mr) {
250
250
  const retrySec = parseInt(process.env.POSTBUILD_MR_RETRY_DELAY || '45', 10);
251
- log(`[postbuild] no open MR for branch=${branch} -- retrying in ${retrySec}s`);
252
- await new Promise((r) => setTimeout(r, retrySec * 1000));
251
+ log(
252
+ `[postbuild] no open MR for branch=${branch} -- retrying in ${retrySec}s`,
253
+ );
254
+ await new Promise(r => setTimeout(r, retrySec * 1000));
253
255
  mr = await client.findOpenMr(projectId, branch);
254
256
  }
255
257
 
@@ -0,0 +1,225 @@
1
+ import { log } from '../lib/logger.mjs';
2
+
3
+ /**
4
+ * @param {string} key
5
+ * @param {string} [fallback]
6
+ * @returns {string}
7
+ */
8
+ function env(key, fallback = '') {
9
+ return process.env[key] || fallback;
10
+ }
11
+
12
+ /**
13
+ * Computes the next semver patch tag from a list of existing tag names.
14
+ * @param {string[]} tagNames
15
+ * @returns {string}
16
+ */
17
+ export function bumpPatch(tagNames) {
18
+ const semverTags = tagNames
19
+ .filter(n => /^v\d+\.\d+\.\d+$/.test(n))
20
+ .map(n => {
21
+ const [major, minor, patch] = n.slice(1).split('.').map(Number);
22
+ return { major, minor, name: n, patch };
23
+ })
24
+ .sort(
25
+ (a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch,
26
+ );
27
+
28
+ if (!semverTags.length) {
29
+ return 'v0.0.1';
30
+ }
31
+
32
+ const latest = semverTags[semverTags.length - 1];
33
+ return `v${latest.major}.${latest.minor}.${latest.patch + 1}`;
34
+ }
35
+
36
+ /**
37
+ * Computes the tag name for the requested environment.
38
+ * @param {string} releaseEnv
39
+ * @param {string[]} existingTags - all tag names (for version bumping)
40
+ * @param {string} [explicitVersion] - explicit version override for production
41
+ * @returns {{ tag: string, skip: boolean, error?: string }}
42
+ */
43
+ export function computeTag(releaseEnv, existingTags = [], explicitVersion) {
44
+ const ts = new Date().toISOString().replace(/[-T:]/g, '').slice(0, 12);
45
+ const tsFormatted = `${ts.slice(0, 8)}.${ts.slice(8)}`;
46
+
47
+ switch (releaseEnv) {
48
+ case 'sandbox':
49
+ return { skip: false, tag: `sb-${tsFormatted}` };
50
+ case 'stage':
51
+ return { skip: false, tag: `rc-${tsFormatted}` };
52
+ case 'production': {
53
+ const next = bumpPatch(existingTags);
54
+ return { skip: false, tag: explicitVersion || next };
55
+ }
56
+ default:
57
+ return {
58
+ error: `Unknown RELEASE_ENV '${releaseEnv}'. ${
59
+ releaseEnv === 'dev'
60
+ ? 'Dev deploys automatically on merge to main — no tag needed.'
61
+ : 'Expected: sandbox, stage, production'
62
+ }`,
63
+ skip: true,
64
+ tag: '',
65
+ };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Posts a Slack message via incoming webhook.
71
+ * @param {string} webhookUrl
72
+ * @param {string} message
73
+ * @param {string} [channel]
74
+ */
75
+ async function postSlack(webhookUrl, message, channel) {
76
+ const payload = { text: message };
77
+ if (channel) {
78
+ payload.channel = channel;
79
+ }
80
+
81
+ const res = await fetch(webhookUrl, {
82
+ body: JSON.stringify(payload),
83
+ headers: { 'Content-Type': 'application/json' },
84
+ method: 'POST',
85
+ });
86
+
87
+ if (!res.ok) {
88
+ log(`[release-promote] Slack notification failed: HTTP ${res.status}`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Fetches all tags from GitLab API.
94
+ * @param {string} apiUrl
95
+ * @param {string} projectId
96
+ * @param {string} token
97
+ * @returns {Promise<string[]>}
98
+ */
99
+ async function fetchTags(apiUrl, projectId, token) {
100
+ const res = await fetch(
101
+ `${apiUrl}/projects/${encodeURIComponent(projectId)}/repository/tags?per_page=100&order_by=version`,
102
+ { headers: { 'PRIVATE-TOKEN': token } },
103
+ );
104
+
105
+ if (!res.ok) {
106
+ log(`[release-promote] Failed to fetch tags: HTTP ${res.status}`);
107
+ return [];
108
+ }
109
+
110
+ const tags = await res.json();
111
+ return tags.map(t => t.name);
112
+ }
113
+
114
+ /**
115
+ * Creates a git tag via GitLab API.
116
+ * @param {string} apiUrl
117
+ * @param {string} projectId
118
+ * @param {string} token
119
+ * @param {string} tagName
120
+ * @param {string} ref
121
+ * @returns {Promise<{ name: string, shortId: string }>}
122
+ */
123
+ async function createTag(apiUrl, projectId, token, tagName, ref) {
124
+ const res = await fetch(
125
+ `${apiUrl}/projects/${encodeURIComponent(projectId)}/repository/tags`,
126
+ {
127
+ body: new URLSearchParams({ ref, tag_name: tagName }),
128
+ headers: {
129
+ 'Content-Type': 'application/x-www-form-urlencoded',
130
+ 'PRIVATE-TOKEN': token,
131
+ },
132
+ method: 'POST',
133
+ },
134
+ );
135
+
136
+ if (!res.ok) {
137
+ const body = await res.text();
138
+ throw new Error(
139
+ `Failed to create tag '${tagName}': HTTP ${res.status} — ${body}`,
140
+ );
141
+ }
142
+
143
+ const data = await res.json();
144
+ return { name: data.name, shortId: data.commit?.short_id || 'unknown' };
145
+ }
146
+
147
+ /**
148
+ * Promote a release to the specified environment by creating a git tag.
149
+ * Reads configuration from environment variables.
150
+ */
151
+ export async function releasePromote() {
152
+ const releaseEnv = env('RELEASE_ENV');
153
+
154
+ if (!releaseEnv) {
155
+ log('[release-promote] RELEASE_ENV not set — skipping.');
156
+ return;
157
+ }
158
+
159
+ // Dev deploys automatically when merged to main — no tag, no promotion.
160
+ // Guard at the code level so even a manual RELEASE_ENV=dev is a clean no-op.
161
+ if (releaseEnv === 'dev') {
162
+ log(
163
+ '[release-promote] Dev deploys automatically on merge to main — no tag needed. Skipping.',
164
+ );
165
+ return;
166
+ }
167
+
168
+ log('══════════════════════════════════════════');
169
+ log(`[release-promote] Environment: ${releaseEnv}`);
170
+ log('══════════════════════════════════════════');
171
+
172
+ const token = env('RELEASE_TOKEN');
173
+ const apiUrl = env('CI_API_V4_URL', 'https://gitlab.com/api/v4');
174
+ const projectId = env('CI_PROJECT_ID');
175
+ const projectName = env('CI_PROJECT_NAME', 'app');
176
+ const triggeredBy = env('GITLAB_USER_LOGIN', 'ci');
177
+ const ref = env('RELEASE_SHA') || env('PROMOTE_DEFAULT_BRANCH', 'main');
178
+
179
+ if (!token) {
180
+ throw new Error(
181
+ 'RELEASE_TOKEN CI variable not set. ' +
182
+ 'Set it as a project CI variable (PAT or deploy token with write_repository scope).',
183
+ );
184
+ }
185
+
186
+ let existingTags = [];
187
+ if (releaseEnv === 'production') {
188
+ existingTags = await fetchTags(apiUrl, projectId, token);
189
+ }
190
+
191
+ const explicitVersion = env('RELEASE_VERSION');
192
+ const result = computeTag(
193
+ releaseEnv,
194
+ existingTags,
195
+ explicitVersion || undefined,
196
+ );
197
+
198
+ if (result.error) {
199
+ throw new Error(result.error);
200
+ }
201
+
202
+ log(`Tag: ${result.tag}`);
203
+ log(`Ref: ${ref}`);
204
+ log('');
205
+
206
+ const created = await createTag(apiUrl, projectId, token, result.tag, ref);
207
+ log(`Tag '${created.name}' created (commit: ${created.shortId})`);
208
+
209
+ log('');
210
+ log('══════════════════════════════════════════');
211
+ log(`[release-promote] Tag '${result.tag}' created on '${ref}'.`);
212
+ log('[release-promote] Promotion pipeline will fire automatically.');
213
+ log('══════════════════════════════════════════');
214
+
215
+ const slackWebhook = env('SLACK_WEBHOOK_URL');
216
+ const slackChannel = env('PROMOTE_SLACK_CHANNEL') || env('SLACK_CHANNEL');
217
+
218
+ if (slackWebhook) {
219
+ const message =
220
+ `:arrow_up: *${projectName}* promoted to *${releaseEnv}* by @${triggeredBy}\n` +
221
+ `Tag: \`${result.tag}\` | Commit: \`${created.shortId}\``;
222
+ await postSlack(slackWebhook, message, slackChannel);
223
+ log('[release-promote] Slack notification sent.');
224
+ }
225
+ }
@@ -0,0 +1,233 @@
1
+ import { log } from '../lib/logger.mjs';
2
+
3
+ /**
4
+ * @param {string} key
5
+ * @param {string} [fallback]
6
+ * @returns {string}
7
+ */
8
+ function env(key, fallback = '') {
9
+ return process.env[key] || fallback;
10
+ }
11
+
12
+ /**
13
+ * Finds the previous tag matching a pattern from a list of tags.
14
+ * Tags are expected to be sorted by creation date (newest first from GitLab API).
15
+ * @param {Array<{name: string, commit: {created_at: string}}>} tags
16
+ * @param {RegExp} pattern
17
+ * @param {string} currentTag - the tag to roll back from (skip it)
18
+ * @returns {{ name: string, commitDate: string } | null}
19
+ */
20
+ export function findPreviousTag(tags, pattern, currentTag) {
21
+ for (const tag of tags) {
22
+ if (tag.name === currentTag) {
23
+ continue;
24
+ }
25
+ if (pattern.test(tag.name)) {
26
+ return { commitDate: tag.commit?.created_at || '', name: tag.name };
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /**
33
+ * Determines the tag pattern for an environment.
34
+ * @param {string} releaseEnv
35
+ * @returns {{ pattern: RegExp, error?: string }}
36
+ */
37
+ export function tagPatternForEnv(releaseEnv) {
38
+ switch (releaseEnv) {
39
+ case 'production':
40
+ return { pattern: /^v\d+\.\d+\.\d+$/ };
41
+ case 'stage':
42
+ return { pattern: /^rc-\d{8}\.\d{4}$/ };
43
+ case 'sandbox':
44
+ return { pattern: /^sb-\d{8}\.\d{4}$/ };
45
+ default:
46
+ return {
47
+ error: `Cannot rollback '${releaseEnv}'. Supported: production, stage, sandbox`,
48
+ pattern: /$^/,
49
+ };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Fetches tags from GitLab API, sorted by creation date descending.
55
+ * @param {string} apiUrl
56
+ * @param {string} projectId
57
+ * @param {string} token
58
+ * @returns {Promise<Array<{name: string, commit: {created_at: string}}>>}
59
+ */
60
+ async function fetchTagsSorted(apiUrl, projectId, token) {
61
+ const res = await fetch(
62
+ `${apiUrl}/projects/${encodeURIComponent(projectId)}/repository/tags?per_page=100&order_by=updated&sort=desc`,
63
+ { headers: { 'PRIVATE-TOKEN': token } },
64
+ );
65
+
66
+ if (!res.ok) {
67
+ throw new Error(`Failed to fetch tags: HTTP ${res.status}`);
68
+ }
69
+
70
+ return res.json();
71
+ }
72
+
73
+ /**
74
+ * Creates a git tag via GitLab API pointing to a specific commit.
75
+ * @param {string} apiUrl
76
+ * @param {string} projectId
77
+ * @param {string} token
78
+ * @param {string} tagName
79
+ * @param {string} ref
80
+ * @returns {Promise<{ name: string, shortId: string }>}
81
+ */
82
+ async function createTag(apiUrl, projectId, token, tagName, ref) {
83
+ const res = await fetch(
84
+ `${apiUrl}/projects/${encodeURIComponent(projectId)}/repository/tags`,
85
+ {
86
+ body: new URLSearchParams({ ref, tag_name: tagName }),
87
+ headers: {
88
+ 'Content-Type': 'application/x-www-form-urlencoded',
89
+ 'PRIVATE-TOKEN': token,
90
+ },
91
+ method: 'POST',
92
+ },
93
+ );
94
+
95
+ if (!res.ok) {
96
+ const body = await res.text();
97
+ throw new Error(
98
+ `Failed to create rollback tag '${tagName}': HTTP ${res.status} — ${body}`,
99
+ );
100
+ }
101
+
102
+ const data = await res.json();
103
+ return { name: data.name, shortId: data.commit?.short_id || 'unknown' };
104
+ }
105
+
106
+ /**
107
+ * Rolls back an environment to the previous release by creating a new tag
108
+ * pointing to the same commit as the previous tag.
109
+ *
110
+ * For production: finds the previous v* tag, creates a new v*.*.* tag
111
+ * For stage: finds the previous rc-* tag, creates a new rc-* tag
112
+ * For sandbox: finds the previous sb-* tag, creates a new sb-* tag
113
+ */
114
+ export async function releaseRollback() {
115
+ const releaseEnv = env('ROLLBACK_ENV') || env('RELEASE_ENV');
116
+
117
+ if (!releaseEnv) {
118
+ log('[release-rollback] ROLLBACK_ENV not set — skipping.');
119
+ return;
120
+ }
121
+
122
+ const token = env('RELEASE_TOKEN');
123
+ if (!token) {
124
+ throw new Error(
125
+ 'RELEASE_TOKEN CI variable not set. ' +
126
+ 'Set it as a project CI variable (PAT or deploy token with write_repository scope).',
127
+ );
128
+ }
129
+
130
+ const apiUrl = env('CI_API_V4_URL', 'https://gitlab.com/api/v4');
131
+ const projectId = env('CI_PROJECT_ID');
132
+ const projectName = env('CI_PROJECT_NAME', 'app');
133
+ const triggeredBy = env('GITLAB_USER_LOGIN', 'ci');
134
+
135
+ log('══════════════════════════════════════════');
136
+ log(`[release-rollback] Rolling back: ${releaseEnv}`);
137
+ log('══════════════════════════════════════════');
138
+
139
+ const { pattern, error } = tagPatternForEnv(releaseEnv);
140
+ if (error) {
141
+ throw new Error(error);
142
+ }
143
+
144
+ const tags = await fetchTagsSorted(apiUrl, projectId, token);
145
+ const currentTags = tags.filter(t => pattern.test(t.name));
146
+
147
+ if (currentTags.length < 2) {
148
+ throw new Error(
149
+ `Cannot rollback: fewer than 2 ${releaseEnv} tags found. Need at least a current and a previous release.`,
150
+ );
151
+ }
152
+
153
+ const currentTag = currentTags[0].name;
154
+ const previous = findPreviousTag(tags, pattern, currentTag);
155
+
156
+ if (!previous) {
157
+ throw new Error(
158
+ `Cannot find a previous ${releaseEnv} tag to rollback to (current: ${currentTag}).`,
159
+ );
160
+ }
161
+
162
+ log(`Current: ${currentTag}`);
163
+ log(`Rollback to: ${previous.name}`);
164
+ log('');
165
+
166
+ const rollbackTagName = env('ROLLBACK_TAG');
167
+ let newTag;
168
+
169
+ if (rollbackTagName) {
170
+ newTag = await createTag(
171
+ apiUrl,
172
+ projectId,
173
+ token,
174
+ rollbackTagName,
175
+ previous.name,
176
+ );
177
+ } else {
178
+ const ts = new Date().toISOString().replace(/[-T:]/g, '').slice(0, 12);
179
+ const tsFormatted = `${ts.slice(0, 8)}.${ts.slice(8)}`;
180
+
181
+ let autoName;
182
+ switch (releaseEnv) {
183
+ case 'production': {
184
+ const parts = currentTag.slice(1).split('.').map(Number);
185
+ autoName = `v${parts[0]}.${parts[1]}.${parts[2] + 1}`;
186
+ break;
187
+ }
188
+ case 'stage':
189
+ autoName = `rc-${tsFormatted}`;
190
+ break;
191
+ case 'sandbox':
192
+ autoName = `sb-${tsFormatted}`;
193
+ break;
194
+ }
195
+
196
+ newTag = await createTag(apiUrl, projectId, token, autoName, previous.name);
197
+ }
198
+
199
+ log(`Rollback tag '${newTag.name}' created (commit: ${newTag.shortId})`);
200
+ log('');
201
+ log('══════════════════════════════════════════');
202
+ log(
203
+ `[release-rollback] ${releaseEnv} rolling back from '${currentTag}' to '${previous.name}' via tag '${newTag.name}'.`,
204
+ );
205
+ log('[release-rollback] Promotion pipeline will fire automatically.');
206
+ log('══════════════════════════════════════════');
207
+
208
+ const slackWebhook = env('SLACK_WEBHOOK_URL');
209
+ const slackChannel = env('PROMOTE_SLACK_CHANNEL') || env('SLACK_CHANNEL');
210
+
211
+ if (slackWebhook) {
212
+ const message =
213
+ `:rotating_light: *${projectName}* rollback on *${releaseEnv}* by @${triggeredBy}\n` +
214
+ `From: \`${currentTag}\` → To: \`${previous.name}\` via tag \`${newTag.name}\``;
215
+
216
+ const payload = { text: message };
217
+ if (slackChannel) {
218
+ payload.channel = slackChannel;
219
+ }
220
+
221
+ const res = await fetch(slackWebhook, {
222
+ body: JSON.stringify(payload),
223
+ headers: { 'Content-Type': 'application/json' },
224
+ method: 'POST',
225
+ });
226
+
227
+ if (!res.ok) {
228
+ log(`[release-rollback] Slack notification failed: HTTP ${res.status}`);
229
+ } else {
230
+ log('[release-rollback] Slack notification sent.');
231
+ }
232
+ }
233
+ }
@@ -189,7 +189,7 @@ Rules:
189
189
  - If all commits are internal-only, write: "• Internal improvements and maintenance"
190
190
  - Do NOT include a header or footer — just the bullet points
191
191
  - Do NOT use Slack formatting like *bold* — plain text only
192
- - Ignore generic release titles like "release: deploy develop to production"
192
+ - Ignore generic release titles like "release: deploy main to production"
193
193
 
194
194
  Commits:
195
195
  ${commitList}${pathsBlock}`;
@@ -236,7 +236,7 @@ export async function releaseSummary() {
236
236
  const toSha = env('CI_COMMIT_SHA', 'HEAD');
237
237
  const branch = env('CI_COMMIT_REF_NAME', 'unknown');
238
238
  const project = env('CI_PROJECT_NAME', 'app');
239
- const sourceBranch = env('RELEASE_SUMMARY_SOURCE_BRANCH', 'develop');
239
+ const sourceBranch = env('RELEASE_SUMMARY_SOURCE_BRANCH', 'main');
240
240
  const googleCreds = env('GOOGLE_CREDENTIALS_JSON');
241
241
 
242
242
  let commits = getCommitsInRange(fromSha, toSha);