@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 +1 -1
- package/ci/assert-full-test-run.yml +18 -0
- package/ci/migration-contract.yml +31 -0
- package/ci/node-test.yml +62 -0
- package/ci/release-promote.yml +55 -0
- package/ci/release-rollback.yml +31 -0
- package/ci/slack-deploy.yml +1 -1
- package/package.json +6 -6
- package/src/cli.mjs +18 -6
- package/src/commands/assert-full-test-run.mjs +93 -0
- package/src/commands/migration-contract.mjs +318 -0
- package/src/commands/postbuild.mjs +4 -2
- package/src/commands/release-promote.mjs +225 -0
- package/src/commands/release-rollback.mjs +233 -0
- package/src/commands/release-summary.mjs +2 -2
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
|
package/ci/node-test.yml
ADDED
|
@@ -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
|
package/ci/slack-deploy.yml
CHANGED
|
@@ -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:
|
|
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.
|
|
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":
|
|
29
|
-
"branches":
|
|
30
|
-
"functions":
|
|
31
|
-
"lines":
|
|
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": "
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
252
|
-
|
|
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
|
|
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', '
|
|
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);
|