-
Notifications
You must be signed in to change notification settings - Fork 0
439 lines (408 loc) · 18.2 KB
/
Copy pathreusable-sf-org-deploy.yml
File metadata and controls
439 lines (408 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
---
# Deploy Salesforce metadata from git to a long-lived org.
#
# build what changed? -> freeze an artifact -> upload
# deploy download -> verify checksum -> deploy|validate -> mark
#
# The job boundary is the artifact boundary: the deploy job has no checkout, so
# it cannot rebuild what it deploys.
#
# Design notes, failure decoder: docs/org-deploy-engine.md
name: Salesforce Org Deploy (Reusable)
on:
workflow_call:
inputs:
environment:
description: "GitHub Environment and Salesforce target, e.g. integration"
required: true
type: string
deploy-mode:
description: "validate = check-only, nothing committed. deploy = for real."
required: false
type: string
default: "validate"
mode:
description: "delta (since the last deploy) or full (every package directory)"
required: false
type: string
default: "delta"
source-dir:
description: "sfdx project root"
required: false
type: string
default: "."
test-level:
description: "Overrides the automatic choice. Empty = let the pipeline decide."
required: false
type: string
default: ""
secret-template-dir:
description: "Templates for secret-bearing metadata, applied after the artifact"
required: false
type: string
default: "config/secret-templates"
container-image:
description: "Exact tag, never latest — the image is the source of tool versions"
required: false
type: string
default: "gforceinnovation/sf-ci:3.1.0"
retention-days:
required: false
type: number
default: 90
secrets:
sf-jwt-key-b64:
required: true
github-app-key-b64:
required: false
owm-api-key:
required: false
outputs:
artifact-name:
value: ${{ jobs.build.outputs.artifact-name }}
component-count:
value: ${{ jobs.build.outputs.component-count }}
deploy-id:
value: ${{ jobs.deploy.outputs.deploy-id }}
# One at a time per org. Never cancel: a half-applied deploy leaves no record.
concurrency:
group: sf-org-deploy-${{ inputs.environment }}
cancel-in-progress: false
jobs:
build:
name: Build artifact
runs-on: ubuntu-latest
# Deliberately NOT environment-scoped, for two reasons.
#
# Ordering: an environment with a required reviewer gates every job that
# declares it. With `environment:` here, production asked for approval
# BEFORE the artifact existed — the reviewer saw no component list, no
# checksum, nothing. Approving a deployment whose contents are unknown is
# not a review. The gate belongs on `deploy`, where there is something to
# review.
#
# Isolation: this job needs no secret. It reads
# config/environments/<env>.json, a committed file. Without an
# `environment:` it cannot reach environment secrets at all, so "the build
# job holds no credentials" is enforced rather than merely observed.
# No `options:` needed — sf-ci 3.1.0+ defaults to the runner's UID.
container: ${{ inputs.container-image }}
defaults:
run:
shell: bash
permissions:
contents: read
outputs:
artifact-name: ${{ steps.build.outputs.artifact-name }}
artifact-sha256: ${{ steps.build.outputs.artifact-sha256 }}
component-count: ${{ steps.build.outputs.component-count }}
destructive-count: ${{ steps.build.outputs.destructive-count }}
tests: ${{ steps.tests.outputs.tests }}
mode: ${{ steps.plan.outputs.mode }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Plan the deployment
id: plan
env:
ENVIRONMENT: ${{ inputs.environment }}
MODE: ${{ inputs.mode }}
SOURCE_DIR: ${{ inputs.source-dir }}
run: |
set -euo pipefail
git config --global --add safe.directory "$GITHUB_WORKSPACE"
cd "$SOURCE_DIR"
# Fail here if the environment has no config, rather than 100 lines
# later inside a convert. The VALUES are exported by sf-artifact-build,
# which is the action that runs the replacement — see its
# env-config-file input.
CONFIG="config/environments/$ENVIRONMENT.json"
[ -f "$CONFIG" ] || { echo "::error::Missing $CONFIG"; exit 1; }
# deployed/<env> marks what this org last received. No tag means a
# first deployment, which needs everything. checkout with
# fetch-depth: 0 already brought every tag, so no fetch is needed.
BASE="0000000"
if [ "$MODE" = "delta" ]; then
if git rev-parse --verify --quiet "refs/tags/deployed/$ENVIRONMENT^{commit}" >/dev/null; then
BASE=$(git rev-parse "refs/tags/deployed/$ENVIRONMENT^{commit}")
echo "Base: deployed/$ENVIRONMENT -> ${BASE:0:7}"
else
echo "::notice::No deployed/$ENVIRONMENT tag — first deployment, using full mode."
MODE=full
fi
fi
{
echo "mode=$MODE"
echo "base=$BASE"
} >> "$GITHUB_OUTPUT"
- name: What changed
id: delta
if: steps.plan.outputs.mode == 'delta'
uses: Gforce-Innovation-Kft/shared-github-actions/.github/actions/sf-source-delta@v2
with:
from-ref: ${{ steps.plan.outputs.base }}
to-ref: ${{ github.sha }}
source-dir: ${{ inputs.source-dir }}
output-dir: delta
# From packageDirectories, never from `.` — a repo-root scan picks up
# anything that merely looks like metadata and then fails to convert it.
- name: Everything (full mode)
if: steps.plan.outputs.mode == 'full'
working-directory: ${{ inputs.source-dir }}
run: |
set -euo pipefail
mkdir -p delta/package
ARGS=()
while IFS= read -r DIR; do
if [ -d "$DIR" ]; then
ARGS+=(--source-dir "$DIR")
else
echo "::warning::packageDirectory '$DIR' missing — checked out with submodules?"
fi
done < <(jq -r '.packageDirectories[].path' sfdx-project.json)
[ ${#ARGS[@]} -gt 0 ] || { echo "::error::No packageDirectory exists on disk."; exit 1; }
sf project generate manifest "${ARGS[@]}" --output-dir delta/package --name package
echo "Full manifest: $(grep -c '<members>' delta/package/package.xml) component(s)"
- name: Nothing changed?
id: gate
env:
MODE: ${{ steps.plan.outputs.mode }}
HAS_CHANGES: ${{ steps.delta.outputs.has-changes }}
run: |
set -euo pipefail
if [ "$MODE" = "delta" ] && [ "$HAS_CHANGES" != "true" ]; then
echo "::notice::No deployable metadata changed. Nothing to do."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Pick the Apex tests
id: tests
if: steps.gate.outputs.skip != 'true'
uses: Gforce-Innovation-Kft/shared-github-actions/.github/actions/sf-apex-test-select@v2
with:
package-xml: ${{ inputs.source-dir }}/delta/package/package.xml
source-dir: ${{ inputs.source-dir }}
- name: Freeze the artifact
id: build
if: steps.gate.outputs.skip != 'true'
uses: Gforce-Innovation-Kft/shared-github-actions/.github/actions/sf-artifact-build@v2
with:
manifest-path: delta/package/package.xml
destructive-manifest: ${{ steps.plan.outputs.mode == 'delta' && 'delta/destructiveChanges/destructiveChanges.xml' || '' }}
source-dir: ${{ inputs.source-dir }}
mode: ${{ steps.plan.outputs.mode }}
environment: ${{ inputs.environment }}
output-dir: artifact
base-commit: ${{ steps.plan.outputs.base }}
head-commit: ${{ github.sha }}
retention-days: ${{ inputs.retention-days }}
# Bundled INTO the artifact: the deploy job has no checkout, so this
# is the only way it can reach them — and it means a rollback gets
# the templates that match the artifact it restores.
secret-template-dir: ${{ inputs.source-dir }}/${{ inputs.secret-template-dir }}
# Bundled too: templates mix secrets (from the deploy environment)
# with these non-secret values (from a file the deploy job cannot read).
env-config-file: ${{ inputs.source-dir }}/config/environments/${{ inputs.environment }}.json
# Keeps secrets out of a retained artifact. Absent tooling warns;
# a detection always fails.
# A hard gate as of sf-ci 3.1.0, which ships gitleaks. A missing scanner
# now fails rather than warns: "we could not check" must not read as
# "we checked and it was fine".
- name: Scan for secrets
if: steps.gate.outputs.skip != 'true'
env:
ARTIFACT: ${{ inputs.source-dir }}/artifact
run: |
set -euo pipefail
if ! command -v gitleaks >/dev/null 2>&1; then
echo "::error::gitleaks is not in ${{ inputs.container-image }}. The artifact cannot be scanned, so it will not be uploaded. Use sf-ci 3.1.0 or later."
exit 1
fi
# --redact keeps the value out of the log; the JSON report still
# carries rule, file and line. A gate that blocks without saying what
# it found is a gate people route around.
if gitleaks dir "$ARTIFACT" --no-banner --redact \
--report-format json --report-path /tmp/gitleaks.json; then
echo "No secrets detected in the artifact."
exit 0
fi
echo "::error::Secret detected in the deployment artifact. Not uploading."
# shellcheck disable=SC2016 # ${...} below are JS template literals
node -e '
const fs = require("fs");
let f = [];
try { f = JSON.parse(fs.readFileSync("/tmp/gitleaks.json", "utf8")) ?? []; } catch {}
if (!f.length) { console.error(" (gitleaks wrote no report — rerun without --redact to inspect)"); }
for (const x of f) {
console.error(` rule=${x.RuleID} file=${x.File} line=${x.StartLine}`);
}
console.error("");
console.error("A secret must never be baked into an artifact: the artifact is uploaded");
console.error("and retained, so the credential is retained with it. Move it to");
console.error("config/secret-templates/ — applied after deployment, never inside it.");
console.error("If this is a false positive, add a gitleaks allowlist entry rather than");
console.error("removing the gate.");
' >&2
exit 1
- uses: actions/upload-artifact@v4
if: steps.gate.outputs.skip != 'true'
with:
name: ${{ steps.build.outputs.artifact-name }}
path: ${{ inputs.source-dir }}/artifact
retention-days: ${{ inputs.retention-days }}
if-no-files-found: error
deploy:
name: ${{ inputs.deploy-mode == 'validate' && 'Validate' || 'Deploy' }}
needs: build
if: needs.build.outputs.artifact-name != ''
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
url: ${{ steps.login.outputs.instance-url }}
# No `options:` needed — sf-ci 3.1.0+ defaults to the runner's UID.
container: ${{ inputs.container-image }}
defaults:
run:
shell: bash
permissions:
contents: write # only to move deployed/<env>
outputs:
deploy-id: ${{ steps.deploy.outputs.deploy-id }}
# No actions/checkout, deliberately: this job must not be able to rebuild
# what it deploys. See docs/org-deploy-engine.md.
steps:
- uses: actions/download-artifact@v4
with:
name: ${{ needs.build.outputs.artifact-name }}
path: artifact
- name: Authenticate
id: login
uses: Gforce-Innovation-Kft/shared-github-actions/.github/actions/sf-org-login@v2
with:
auth-method: jwt
credential-source: github-env
jwt-key-b64: ${{ secrets.sf-jwt-key-b64 }}
username: ${{ vars.SF_USERNAME }}
client-id: ${{ vars.SF_CLIENT_ID }}
instance-url: ${{ vars.SF_INSTANCE_URL }}
org-alias: target
# Salesforce requires 75% coverage on every Apex class in the deployment,
# so the test level has to match the deployment's scope:
#
# full RunLocalTests. RunSpecifiedTests would run only the tests the
# delta selected, leaving every other class at 0% coverage and
# failing with "Test coverage of selected Apex Class is 0%".
# delta RunSpecifiedTests when tests were selected — much cheaper than
# running everything on an org carrying unrelated packages.
- name: Pick the test level
id: policy
env:
OVERRIDE: ${{ inputs.test-level }}
MODE: ${{ needs.build.outputs.mode }}
TESTS: ${{ needs.build.outputs.tests }}
run: |
set -euo pipefail
if [ -n "$OVERRIDE" ]; then LEVEL="$OVERRIDE"; WHY="override"
elif [ "$MODE" = "full" ]; then LEVEL="RunLocalTests"; WHY="full deployment needs coverage for every class"
elif [ -n "$TESTS" ]; then LEVEL="RunSpecifiedTests"; WHY="delta selected tests"
else LEVEL="RunLocalTests"; WHY="delta selected no tests"
fi
echo "test-level=$LEVEL" >> "$GITHUB_OUTPUT"
echo "$LEVEL ($WHY)${TESTS:+ — $TESTS}"
- name: ${{ inputs.deploy-mode == 'validate' && 'Validate' || 'Deploy' }} the artifact
id: deploy
uses: Gforce-Innovation-Kft/shared-github-actions/.github/actions/sf-artifact-deploy@v2
with:
artifact-path: artifact
expected-sha256: ${{ needs.build.outputs.artifact-sha256 }}
org-alias: target
deploy-mode: ${{ inputs.deploy-mode }}
test-level: ${{ steps.policy.outputs.test-level }}
# RunLocalTests ignores this; RunSpecifiedTests requires it.
tests: ${{ needs.build.outputs.tests }}
# Secrets reach the org here — after the artifact, and never inside it.
# Skipped on validate: a check-only run commits nothing, so writing real
# credentials would be the one side effect "nothing is committed" must
# not have.
- name: Apply environment secrets
if: inputs.deploy-mode == 'deploy'
uses: Gforce-Innovation-Kft/shared-github-actions/.github/actions/sf-env-config-apply@v2
with:
# From the artifact, not from source — this job has no checkout.
template-dir: artifact/secret-templates
org-alias: target
env:
GITHUB_APP_KEY_B64: ${{ secrets.github-app-key-b64 }}
OWM_API_KEY: ${{ secrets.owm-api-key }}
# Only after a real deploy. A validate run changed nothing, so moving the
# marker would make the next delta skip components that never landed.
- name: Mark this commit as deployed
if: inputs.deploy-mode == 'deploy'
env:
ENVIRONMENT: ${{ inputs.environment }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# curl, not gh: the sf-ci container has no GitHub CLI. And not
# `git clone --bare` either — that downloads the entire history to
# give `git tag` an object to point at, for what is one ref update.
API="https://api.github.com/repos/$GITHUB_REPOSITORY/git/refs"
REF="tags/deployed/$ENVIRONMENT"
api() {
curl -sS -o /tmp/ref-response.json -w '%{http_code}' \
-X "$1" "$2" \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
-d "$3"
}
# PATCH updates an existing ref; 422 means it does not exist yet, so
# create it. Checking first would cost a second round-trip.
CODE=$(api PATCH "$API/$REF" "{\"sha\":\"$GITHUB_SHA\",\"force\":true}")
if [ "$CODE" = "422" ] || [ "$CODE" = "404" ]; then
CODE=$(api POST "$API" "{\"ref\":\"refs/$REF\",\"sha\":\"$GITHUB_SHA\"}")
fi
case "$CODE" in
200|201) echo "deployed/$ENVIRONMENT -> ${GITHUB_SHA:0:7}" ;;
*)
echo "::error::Could not move deployed/$ENVIRONMENT (HTTP $CODE). The deployment succeeded, so the next delta will recompute from the old base and redeploy these components." >&2
cat /tmp/ref-response.json >&2
exit 1
;;
esac
- name: Summary
if: always()
env:
MODE: ${{ inputs.deploy-mode }}
ENVIRONMENT: ${{ inputs.environment }}
ARTIFACT: ${{ needs.build.outputs.artifact-name }}
COMPONENTS: ${{ needs.build.outputs.component-count }}
DELETIONS: ${{ needs.build.outputs.destructive-count }}
TEST_LEVEL: ${{ steps.policy.outputs.test-level }}
DEPLOY_ID: ${{ steps.deploy.outputs.deploy-id }}
ORG: ${{ steps.login.outputs.instance-url }}
run: |
set -euo pipefail
{
if [ "$MODE" = "validate" ]; then
echo "# Validated against $ENVIRONMENT"
echo ""
echo "> Check-only. The org compiled and tested this artifact; nothing was committed,"
echo "> and \`deployed/$ENVIRONMENT\` was deliberately not moved."
else
echo "# Deployed to $ENVIRONMENT"
fi
echo ""
echo "| | |"
echo "|---|---|"
echo "| Commit | \`${GITHUB_SHA:0:7}\` |"
echo "| Artifact | \`$ARTIFACT\` |"
echo "| Components | ${COMPONENTS:-0} (${DELETIONS:-0} deletion/s) |"
echo "| Tests | ${TEST_LEVEL:-n/a} |"
echo "| Salesforce id | \`${DEPLOY_ID:-n/a}\` |"
echo "| Org | ${ORG:-n/a} |"
echo "| Result | ${{ job.status }} |"
} >> "$GITHUB_STEP_SUMMARY"