Skip to content

Merge branch 'main' into feat/LLMO-5751-last-mod-missing-sync #9372

Merge branch 'main' into feat/LLMO-5751-last-mod-missing-sync

Merge branch 'main' into feat/LLMO-5751-last-mod-missing-sync #9372

Workflow file for this run

name: Build
# IMPORTANT: npm Trusted Publisher bindings for the 21 published @adobe/* packages point
# to this file (adobe/spacecat-shared / .github/workflows/main.yaml). Renaming or moving
# this file breaks OIDC publishing — re-run scripts/setup-npm-trusted-publishers.sh after.
# Renaming the 'npm-publish' GitHub Environment referenced below has the same silent-break
# failure mode — re-run the setup script after.
#
# Trust binding security relies on the 'npm-publish' GitHub Environment having a
# deployment_branch_policy that allows only `main`. That branch policy is the server-side
# enforcement of the OIDC token's `environment` claim — without it, the binding's
# `--environment npm-publish` filter is decorative. Branch protection on `main` (PR review
# required, disallow direct push) is the second leg of the security model.
#
# Required reviewers on the 'npm-publish' environment are INTENTIONALLY disabled to
# avoid a single-timezone bottleneck on routine releases (semantic-release auto-publishes
# every meaningful merge to `main`). To re-enable a human gate, see "Re-enabling the
# environment approval gate" in docs/RELEASE-RUNBOOK.md. Branch protection on `main`
# remains the load-bearing PR review gate; the env approval is a release-stamp on
# something already approved at PR time.
# See: https://docs.npmjs.com/trusted-publishers
# No workflow-level permissions — each job declares only what it needs.
permissions: {}
on:
push:
# workflow_dispatch enables manual re-trigger for incident recovery
# (see docs/RELEASE-RUNBOOK.md "Failure mode 3"). The release job's
# `if: github.ref == 'refs/heads/main'` and `environment: npm-publish`
# (deployment_branch_policy: main-only, no required reviewers — see header)
# still gate publish.
workflow_dispatch:
env:
CI_BUILD_NUM: ${{ github.run_id }}
CI_BRANCH: ${{ github.ref_name }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
permissions:
id-token: write # AWS ECR OIDC (data-access IT tests)
contents: read
issues: read
steps:
- name: Check out
uses: actions/checkout@v6
with:
persist-credentials: 'false'
- name: Verify SR_NO_NPM_AUTH guard consistency
# @semantic-release/npm must be skipped in the Test-job dry-run: that job has no
# npm publish auth (OIDC works only in the release job, which has id-token: write
# + the npm-publish environment), so the plugin's verifyConditions npm-whoami would
# fail here. The skip is driven by SR_NO_NPM_AUTH: 'true' on the dry-run step below,
# gated in every .releaserc.cjs. This is PERMANENT, not a bootstrap shim — #1592
# added SR_NO_NPM_AUTH in the same change that removed NPM_TOKEN from the dry-run.
# This step enforces that the env var AND the guard in every .releaserc.cjs stay
# present; neither may be removed (doing so red-lines the dry-run on every PR).
#
# The anchored regex (indented key + 'true' | "true" | true) is robust to quote-style
# reformatting from Prettier/yamllint and is syntactically distinct from the JS
# strict-equality this step looks for in configs, so it cannot self-reference.
run: |
set -euo pipefail
shopt -s nullglob
files=(.releaserc.cjs packages/*/.releaserc.cjs)
if [ ${#files[@]} -eq 0 ]; then
echo "FAIL: no .releaserc.cjs files found — guard check cannot verify anything."
echo " Has the monorepo layout changed? Update this step's file glob."
exit 1
fi
# Per-file existence assertion: catches a stale glob expansion or a
# symlink-to-nowhere that would otherwise turn into grep exit code 2
# (silently swallowed by `|| true` below).
for f in "${files[@]}"; do
[ -f "$f" ] || { echo "FAIL: expected config not found or not a regular file: $f"; exit 1; }
done
# The SR_NO_NPM_AUTH skip is permanent (see comment above). Enforce BOTH legs:
# the workflow env var must stay declared, and every .releaserc.cjs must keep the
# matching guard. Removing either breaks the dry-run.
#
# Anchor: YAML mapping `<indent>SR_NO_NPM_AUTH: 'true' | "true" | true` at
# end-of-value (no trailing chars that would denote a different key/value).
if ! grep -qE "^[[:space:]]+SR_NO_NPM_AUTH:[[:space:]]+('true'|\"true\"|true)[[:space:]]*\$" .github/workflows/main.yaml; then
echo "FAIL: SR_NO_NPM_AUTH: 'true' is missing from the dry-run step env."
echo ""
echo "It is required and permanent — the Test-job dry-run has no npm publish auth,"
echo "so @semantic-release/npm must be skipped there. Restore it; do not remove it."
exit 1
fi
# Every .releaserc.cjs must include the strict guard. Accept both JS quote styles
# so Prettier reformats don't cause spurious fails.
missing=$(grep -L -E "SR_NO_NPM_AUTH === ['\"]true['\"]" "${files[@]}" || true)
if [ -n "$missing" ]; then
echo "FAIL: SR_NO_NPM_AUTH guard missing in:"
echo "$missing"
echo ""
echo "Each .releaserc.cjs must gate @semantic-release/npm on:"
echo " ...(process.env.SR_NO_NPM_AUTH === 'true' ? [] : [\"@semantic-release/npm\"]),"
exit 1
fi
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Node & NPM
uses: ./.github/actions/setup-node-npm
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
env:
MYSTICAT_DATA_SERVICE_REPO_READ_TOKEN: ${{ secrets.MYSTICAT_DATA_SERVICE_REPO_READ_TOKEN }}
- name: Verify package-lock.json is in sync
# Lockfile drift guard. semantic-release bumps each package's
# package.json + CHANGELOG on release but does NOT regenerate the root
# package-lock.json (the @semantic-release/git `assets` list in every
# .releaserc.cjs is package.json + CHANGELOG.md only). When a released
# workspace version later crosses a consumer's exact internal pin (e.g.
# cloud-manager-client -> spacecat-shared-ims-client), the lock loses
# the matching resolution and EVERY branch then fails `npm ci` with
# "Missing <pkg>@<ver> from lock file" — a main-wide breakage that first
# surfaces on unrelated PRs. Regenerate the lock with the same pinned
# npm (11.13.0, installed by the Setup Node & NPM composite above) and
# fail if it changed, so drift is caught on the PR that introduces it.
# The Release job keeps main's lock in sync after every release (see the
# "Sync package-lock.json after release" step), so on a healthy main
# this regeneration is a no-op.
shell: bash
run: |
set -euo pipefail
npm install --package-lock-only --ignore-scripts
if ! git diff --quiet -- package-lock.json; then
echo "FAIL: package-lock.json is out of sync with the workspace package.json files."
echo " Fix: run 'npm install --package-lock-only' locally and commit package-lock.json."
echo "----- drift -----"
git --no-pager diff -- package-lock.json
exit 1
fi
echo "OK: package-lock.json is in sync."
- name: Lint, Test, Coverage Upload
uses: ./.github/actions/lint-test-coverage
with:
ecr_pull_role_arn: ${{ secrets.AWS_ECR_PULL_ROLE_ARN }}
aws_region: us-east-1
- name: Semantic Release (Dry Run)
if: github.ref != 'refs/heads/main'
run: npm run semantic-release-dry
env:
GITHUB_TOKEN: ${{ secrets.ADOBE_BOT_GITHUB_TOKEN }}
# SR_NO_NPM_AUTH skips @semantic-release/npm in the plugin chain (see all .releaserc.cjs).
# PERMANENT, not a bootstrap shim: this dry-run has no npm publish auth (OIDC is
# release-job-only), so @semantic-release/npm's verifyConditions npm-whoami would fail
# here without it. #1592 added it in the same change that removed NPM_TOKEN from this
# step. Do not remove (the "Verify SR_NO_NPM_AUTH guard consistency" step enforces it).
SR_NO_NPM_AUTH: 'true'
release:
name: Release
runs-on: ubuntu-latest
needs: test
# Skip the release job on the chore(release) commits semantic-release pushes
# back to main. The guard matches semantic-release-monorepo's actual commit
# subject prefix (`chore(release):`) rather than the substring `[skip ci]`
# anywhere in the message — a substring match misfires on any commit whose
# body or PR description quotes the skip-ci convention (which trapped this
# PR's own commit 395f4921; see commit 2475850e for the workaround).
# Belt-and-suspenders: also AND with [skip ci] presence so a future
# chore(release): subject without [skip ci] does not get silently skipped.
if: github.ref == 'refs/heads/main' && !(startsWith(github.event.head_commit.message, 'chore(release)') && contains(github.event.head_commit.message, '[skip ci]'))
environment: npm-publish
timeout-minutes: 15
concurrency:
# Serialize releases on main so semantic-release's tag-then-publish flow
# (which assumes serialized execution) cannot race when two PRs land close
# together. cancel-in-progress: false — a half-completed release leaves
# inconsistent state on npm + git tags; let an in-flight one finish.
group: npm-publish-main
cancel-in-progress: false
permissions:
id-token: write # npm OIDC Trusted Publishing
contents: write
issues: read
env:
MYSTICAT_DATA_SERVICE_REPO_READ_TOKEN: ${{ secrets.MYSTICAT_DATA_SERVICE_REPO_READ_TOKEN }}
# NPM_CONFIG_PROVENANCE makes sigstore (status.sigstore.dev) a release prerequisite.
# Sigstore outage break-glass: temporarily set to 'false' to publish without
# provenance attestation for that release, then revert once sigstore recovers.
NPM_CONFIG_PROVENANCE: 'true'
steps:
- name: Check out
uses: actions/checkout@v6
with:
persist-credentials: 'false'
- name: Set up Node.js
uses: actions/setup-node@v6
with:
# Single source of truth — keeps the release job's Node version in
# lockstep with the test job's (which pulls from .nvmrc via the
# setup-node-npm composite action). A future .nvmrc bump no longer
# has to be applied in two places.
node-version-file: '.nvmrc'
- name: Update NPM
run: npm install -g npm@11.13.0
- name: Configure Git auth for private Mysticat types repo
run: |
git config --global url."https://x-access-token:${MYSTICAT_DATA_SERVICE_REPO_READ_TOKEN}@github.com/adobe/mysticat-data-service".insteadOf "https://github.com/adobe/mysticat-data-service"
git config --global --add url."https://x-access-token:${MYSTICAT_DATA_SERVICE_REPO_READ_TOKEN}@github.com/adobe/mysticat-data-service".insteadOf "ssh://git@github.com/adobe/mysticat-data-service"
git config --global --add url."https://x-access-token:${MYSTICAT_DATA_SERVICE_REPO_READ_TOKEN}@github.com/adobe/mysticat-data-service".insteadOf "git@github.com:adobe/mysticat-data-service"
- name: Install dependencies
run: npm ci
- name: Semantic Release
run: npm run semantic-release
env:
GITHUB_TOKEN: ${{ secrets.ADOBE_BOT_GITHUB_TOKEN }}
- name: Sync package-lock.json after release
# semantic-release bumps the released package versions but does not
# regenerate the root package-lock.json (see the Test job's drift
# guard). Left unsynced, the lock's resolutions for internal exact pins
# go stale the moment a released workspace version crosses a consumer's
# pin, breaking `npm ci` on every branch. Regenerate the lock against
# the just-published versions and, if it changed, commit it straight to
# main with [skip ci] (GitHub natively skips workflow runs for commits
# whose head message contains [skip ci], so this triggers no new CI or
# release run). Best-effort: this must NEVER fail a release — the
# packages are already published and the Test-job drift guard is the
# backstop — hence continue-on-error. If the push loses a race it is
# left for the next PR's drift guard to surface.
if: success()
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ secrets.ADOBE_BOT_GITHUB_TOKEN }}
run: |
set -euo pipefail
# continue-on-error keeps this step from failing a release, which also
# means a failure is otherwise invisible. Surface ANY failure (regen,
# commit, or the push+rebase retry) as a workflow ::warning:: + run
# summary line so the operator sees it without digging through logs;
# the next PR's drift guard is still the backstop (RELEASE-RUNBOOK
# Failure mode 7). errexit makes a failed `npm install` an honest red
# mark instead of a false "already in sync" — the `if ! git push`
# retry below is errexit-safe (the negated condition suppresses it).
trap 'echo "::warning title=package-lock sync after release failed::Lockfile sync did not complete; see RELEASE-RUNBOOK Failure mode 7. The next PR drift guard will surface residual drift."; echo "package-lock.json sync after release failed — see RELEASE-RUNBOOK Failure mode 7." >> "$GITHUB_STEP_SUMMARY"' ERR
npm install --package-lock-only --ignore-scripts
if git diff --quiet -- package-lock.json; then
echo "OK: package-lock.json already in sync; nothing to commit."
exit 0
fi
echo "package-lock.json drifted after release; committing a sync commit to main."
git config user.name "semantic-release-bot"
git config user.email "semantic-release-bot@martynus.net"
git add package-lock.json
# `chore(release):` subject so the release-job skip guard above also
# skips this commit (belt-and-suspenders alongside the native [skip ci]
# skip), matching how that guard is documented.
git commit -m "chore(release): sync package-lock.json after release [skip ci]"
# Authenticate via http.extraHeader (base64 Basic auth) rather than
# embedding the token in the remote URL. git echoes the remote URL in
# push/pull error output, so a URL-embedded token can leak into logs if
# Actions' secret masking misses the exact substring; the header value
# is never echoed. Mirrors how actions/checkout injects credentials.
# Pre-mask the base64 form (which is NOT auto-masked, being a distinct
# string from the raw secret) before it can reach any log line.
auth_b64="$(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')"
echo "::add-mask::${auth_b64}"
remote="https://github.com/${GITHUB_REPOSITORY}.git"
git_auth() { git -c "http.extraheader=AUTHORIZATION: basic ${auth_b64}" "$@"; }
# Releases are serialized (concurrency: npm-publish-main), so a race is
# unlikely, but retry once against the latest main just in case. KNOWN
# LIMITATION: a SECOND concurrent push landing within this retry window
# still loses — by design we do not loop. continue-on-error keeps it
# from failing the release, the ERR trap surfaces a ::warning::, and the
# residual drift is recovered via the next PR's drift guard +
# RELEASE-RUNBOOK Failure mode 7 (the manual lockfile-only PR).
if ! git_auth push "${remote}" HEAD:main; then
echo "push rejected; rebasing on latest main and retrying once."
git_auth pull --rebase "${remote}" main
git_auth push "${remote}" HEAD:main
fi
echo "Pushed package-lock.json sync to main."
- name: Surface failure pointer to release runbook
# On failure OR in-progress cancellation, write a pointer to the runbook
# into $GITHUB_STEP_SUMMARY so the on-call sees the recovery procedures
# in the PR check UI rather than having to remember where the runbook
# lives. cancelled() covers a mid-flight cancel (e.g. operator cancels
# during `npm publish`) — it does NOT fire for an FM-6 env-approval
# stall, since a job cancelled while still "Waiting for approval" never
# runs any step. The runbook itself does not depend on this pointer for
# FM-6 recovery.
if: failure() || cancelled()
shell: bash
env:
# Absolute URL — relative `../blob/main/...` from an Actions run page
# would resolve to /<owner>/<repo>/actions/blob/main/... (404).
RUNBOOK_URL: ${{ github.server_url }}/${{ github.repository }}/blob/main/docs/RELEASE-RUNBOOK.md
run: |
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## Release job did not complete successfully
See [\`docs/RELEASE-RUNBOOK.md\`](${RUNBOOK_URL}):
- **FM-1** — First OIDC release fails (revert path)
- **FM-2** — Sigstore unavailable (break-glass: \`NPM_CONFIG_PROVENANCE: 'false'\`)
- **FM-3** — Partial publish (15-min timeout / mid-flow failure)
- **FM-4** — Workflow / environment renamed
- **FM-5** — Setup-script preflight refused to register bindings
- **FM-6** — Env-approval stall
Trust binding: \`{repo: ${{ github.repository }}, workflow: main.yaml, environment: npm-publish}\`.
Tracking: SITES-42702.
EOF