diff --git a/.github/inditextech-ci-sync-manifest.json b/.github/inditextech-ci-sync-manifest.json index 5ecd83d..66c33c6 100644 --- a/.github/inditextech-ci-sync-manifest.json +++ b/.github/inditextech-ci-sync-manifest.json @@ -1,5 +1,9 @@ { "configuration": { + "branch_roles": { + "development_branch": "develop", + "release_branch": "main" + }, "development_flow": "git-flow", "outputs": [ "publish" @@ -13,7 +17,7 @@ "creation_year": 2026, "integrity": { "algorithm": "hmac-sha256", - "signature": "31eb5c1fe000ee6bda8bd74d8c1f273db26cdd9169be565b3bb9e03e36e9c137" + "signature": "08f8880c92dd47eae0bd216f5bcbcc163643a06f5bfcc5517498adf2f1a47217" }, "managed_by": "InditexTech CI governance", "managed_paths": { @@ -23,26 +27,36 @@ ".github/ISSUE_TEMPLATE/config.yml": "10e5dee4d49aa9e7792ceb4c986160c61f95da1ba00a64bf06e5f1f636cd17ec", ".github/PULL_REQUEST_TEMPLATE.md": "23a0b0ac79a9020ccac9c013582a01f86e2df2ae7f914673583b9a3368313041", ".github/inditextech-ci-node.json": "72449d1243d714b1ef9bd615e6dc67d0ec0b25f0c73440a08fa34d1e498864ac", - ".github/workflows/code-npm_node-PR_verify.yml": "29f72ba6e742fc56255f13d27ce650d6ccb2e184d9e2d9647e7c677220b90147", - ".github/workflows/code-npm_node-publish-release-and-snapshot.yml": "58b983b783aecbe835e7fad36f112ac8422dc0e83962cff32991001ebcc2d9ad", - ".github/workflows/code-npm_node-release-core.yml": "d4ec87242c201d99e0a12c42b7f17a0dda5d103759e1bea1620e7ed533232a5c", - ".github/workflows/code-npm_node-sonarcloud-analysis.yml": "8aedcf54db51ae185270ea059728c598caf1b14a5e19d4674180a4513821dd03", - ".github/workflows/code-release_preview.yml": "6222022149ea98df153afac623e1ecb5031f61607bcfda665058275e28c408f7", - ".github/workflows/codeql.yml": "301f1da228963e0586400b54ebdc7605e0a73c6443889cadc0fbde91869a52d6", - ".github/workflows/pr-verify.yml": "f52295fbfe81578c0a6459d8b384be569933ac78d32fff370932a8dec6943e46", - ".github/workflows/push-verify.yml": "217097070d0fdef329eae1a7187806a4623d692ce467ca8f1f9a7f56dddb29ed", - ".github/workflows/sync-to-develop.yml": "9eb4278deb6be51157152068ccd5f1ee91866943ae9225fb9d8512aaacc88708", + ".github/workflows/code-npm_node-PR_verify.yml": "89e11bc8aae9ec44ab47222cc570c27dffb8b426e88665598ea7065a9cc95d54", + ".github/workflows/code-npm_node-publish-release-and-snapshot.yml": "9b9de7bf2f53cfad5c2ab1bc76443844d4aa8958089bf1288f8d35031d55095e", + ".github/workflows/code-npm_node-release-core.yml": "3b409b668d51f67bb683b56905d314fd807de32f4f4c46555ce9de4cb3bc3c73", + ".github/workflows/code-npm_node-sonarcloud-analysis.yml": "70c1c21825cde574d4744b308e198d030dbabddaa0f8ba07980be253ea6d7afa", + ".github/workflows/code-release_preview.yml": "4e2e95d60a498eb12d97ba5c8330b1173f04b02c807140beddad06a6e225b166", + ".github/workflows/codeql.yml": "fcfd5d629157760bcc5666b5193ed01223ab82e176f19844eaef7016278168be", + ".github/workflows/pr-verify.yml": "b39240fd362fb004c3b47cd4189cb527d9e27a7c37645bbe6324d2a757e34f7f", + ".github/workflows/push-verify.yml": "49e6e9c6a7015ea59a2bc831982b2fc4bfd1d5721302f6041170b4a15b7d56e1", + ".github/workflows/scorecard-analysis.yml": "9866047c5d638ee5d9246e646400cad473ce8ab0f66b444337405b8f9f324f6e", + ".github/workflows/sync-to-develop.yml": "6fcb3ce7a9fb55d7322cc50255c35f6244143c6cb708899902d00945adbcd148", ".tool-versions": "5e7b05edf5d8df174df5dd99d012e57666ec9923ac8506df7dc7753ba41815fd", "CODE_OF_CONDUCT.md": "ce1e7a8f68a7917d48c03f9f7aae5529367f73af0e959276e889d33ea1e8d4ab", "CONTRIBUTING.md": "4e1264ca54a45df44b362c7533f0eba912bfa77b6f561121ae9e5e9d6aa00df3", "SECURITY.md": "0ee7a3356bc3a1c7649e3b7a6a9012b0608011be2b1ff8d106ad02c8950bab25", "repolinter.json": "0efb305c47a63f03c488a34906fc2d5471c84145c2ff553f2aacf3a41b92e1a1" }, + "managed_variables": { + "DEVELOPMENT_FLOW": "git-flow", + "GIT_FLOW_DEVELOPMENT_BRANCH": "develop", + "GIT_FLOW_RELEASE_BRANCH": "main", + "PROJECT_TYPE": "single", + "PUBLISH_SNAPSHOT": "true", + "WORKING_DIRECTORY": "." + }, "profile": "node", "repository_id": 1291909277, + "retained_paths": {}, "schema_version": 2, "source_digests": { - "base": "8af88664d64bb62e112d3039ad780df69cc04f0b3fb5090fd12c8e72d8dab553", - "node": "f7151115162e66e928b2b690a5239bbb5c371eab2b2d6449cf019a787319969f" + "base": "e23b6d1e03f8948e8ccc602e054fad71e0d64ff0ddeee534f915babf107edea3", + "node": "d35cb892d1e48292852cdb39e10a31b4583849fd4aeb71c2e4434c7e37c4e82e" } } diff --git a/.github/workflows/code-npm_node-PR_verify.yml b/.github/workflows/code-npm_node-PR_verify.yml index abe7472..a9b5d66 100644 --- a/.github/workflows/code-npm_node-PR_verify.yml +++ b/.github/workflows/code-npm_node-PR_verify.yml @@ -39,7 +39,6 @@ jobs: shell: bash run: | set -euo pipefail - # Reject any .tool-versions line that is not a strict " " before asdf reads it (npm is pinned via package.json packageManager, not here). if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' .tool-versions; then echo "::error title=Invalid tool-versions::${WORKING_DIRECTORY}/.tool-versions is malformed." exit 1 @@ -52,7 +51,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Set up asdf-managed Node - uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.0 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 with: tool_versions: ${{ steps.tool-versions.outputs.tool_versions }} asdf_version: ${{ env.ASDF_BRANCH_VERSION }} @@ -62,7 +61,6 @@ jobs: shell: bash run: | set -euo pipefail - # One install resolves the whole workspace; `verify` runs the governed lifecycle (lint, unit tests, build) across the tree. case "$PROJECT_TYPE" in single|workspaces) : ;; *) @@ -70,14 +68,12 @@ jobs: exit 1 ;; esac - # Package manager defaults to npm; an absent PACKAGE_MANAGER var renders the npm path unchanged (byte-identical to the pre-pnpm template). case "${PACKAGE_MANAGER:-npm}" in npm) npm ci npm run verify ;; pnpm) - # Corepack activates the exact pnpm pinned in the product's package.json "packageManager" field: no floating pnpm, no product edit. corepack enable pnpm install --frozen-lockfile pnpm run verify diff --git a/.github/workflows/code-npm_node-publish-release-and-snapshot.yml b/.github/workflows/code-npm_node-publish-release-and-snapshot.yml index 0fa05ba..03946f3 100644 --- a/.github/workflows/code-npm_node-publish-release-and-snapshot.yml +++ b/.github/workflows/code-npm_node-publish-release-and-snapshot.yml @@ -4,9 +4,7 @@ name: code-npm-node-publish-release-and-snapshot run-name: Publish npm (release or snapshot) from ${{ github.event_name }} -# Single registrable top-level npm publish entrypoint (Topology B): npm trusted publishing keys on the workflow FILENAME, so the privileged `npm publish` runs INLINE here (never in a called reusable workflow) and both `workflow_ref` and `job_workflow_ref` resolve to this one file; snapshot and release lanes are separate jobs that hand a pre-built tarball to the pinned gh-actions/npm composite ACTION inside a reviewer-gated Environment, and the unprivileged build stage is the only one running candidate code. on: - # workflow_dispatch drives the RELEASE lane (it carries release_type); the snapshot lane fires on push / issue_comment only, so a manual dispatch never cuts a snapshot. workflow_dispatch: inputs: release_type: @@ -32,7 +30,6 @@ permissions: contents: read concurrency: - # Lane-separated group key so snapshot and release runs never share a group: snapshot (push / issue_comment) is cancel-in-progress, release (pull_request / workflow_dispatch) is not. group: >- ${{ format('code-npm-node-publish-{0}-{1}', github.repository, (github.event_name == 'push' || github.event_name == 'issue_comment') @@ -48,9 +45,6 @@ env: SNAPSHOT_ARTIFACT: npm-snapshot-dist jobs: - # Snapshot lane (jobs: authorize -> build -> publish). - - # Unprivileged pre-gate for /publish-snapshot: a cheap read-only filter, not a substitute for the reviewer-gated npm-snapshot Environment; resolves the PR head so the build stage checks out the reviewed commit. authorize: name: Authorize snapshot command if: >- @@ -62,6 +56,7 @@ jobs: timeout-minutes: 5 permissions: contents: read + pull-requests: read outputs: head_sha: ${{ steps.resolve.outputs.head_sha }} steps: @@ -73,18 +68,22 @@ jobs: shell: bash run: | set -euo pipefail - # Read-only lookup of the PR head SHA; issue_comment carries the number, not the head commit. - head_sha="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha')" + head="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '[.head.sha, .head.repo.full_name] | @tsv')" + head_sha="$(printf '%s' "$head" | cut -f1)" + head_repo="$(printf '%s' "$head" | cut -f2)" if [[ -z "$head_sha" || "$head_sha" == "null" ]]; then echo "::error title=Unresolved head::Could not resolve the pull request head commit." >&2 exit 1 fi + if [[ "$head_repo" != "$GITHUB_REPOSITORY" ]]; then + echo "::error title=Fork head rejected::Snapshot publishing only accepts branches of ${GITHUB_REPOSITORY}; refusing head from ${head_repo}." >&2 + exit 1 + fi echo "head_sha=${head_sha}" >> "$GITHUB_OUTPUT" build: name: Build snapshot needs: [authorize] - # Snapshot builds fire on the trusted release line (push) or an authorized /publish-snapshot comment only; workflow_dispatch is reserved for releases. if: >- !cancelled() && ( @@ -122,13 +121,11 @@ jobs: shell: bash run: | set -euo pipefail - # Snapshot publishing is opt-in (descriptor `publish_snapshot`, default off); an absent PUBLISH_SNAPSHOT variable disables the deploy on both the push and comment lanes. if [[ "${PUBLISH_SNAPSHOT:-false}" != "true" ]]; then echo "::notice title=Snapshot disabled::publish_snapshot opt-in is off; no snapshot is published." echo "should_publish=false" >> "$GITHUB_OUTPUT" exit 0 fi - # The next-development commit already restores -SNAPSHOT, so skip it; key on the release-bot committer identity `[bot]@users.noreply.github.com` rather than the commit subject, which the project owns in delegated mode. head_committer_email="$(git show -s --format=%ce HEAD)" if [[ "$GITHUB_EVENT_NAME" == "push" && "$head_committer_email" == *"[bot]@users.noreply.github.com" ]]; then echo "::notice title=Snapshot skipped::Next-dev commit does not need a snapshot." @@ -144,7 +141,6 @@ jobs: shell: bash run: | set -euo pipefail - # Reject any .tool-versions line that is not a strict " " before asdf reads it (npm is pinned via package.json packageManager, not here). if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' .tool-versions; then echo "::error title=Invalid tool-versions::${WORKING_DIRECTORY}/.tool-versions is malformed." exit 1 @@ -158,7 +154,7 @@ jobs: - name: Set up asdf-managed Node if: steps.plan.outputs.should_publish == 'true' - uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.0 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 with: tool_versions: ${{ steps.tool-versions.outputs.tool_versions }} asdf_version: ${{ env.ASDF_BRANCH_VERSION }} @@ -175,22 +171,19 @@ jobs: set -euo pipefail strip_snapshot() { printf '%s' "${1%-SNAPSHOT}"; } - compute_snapshot() { # -> snapshot version for this run + compute_snapshot() { if [[ "$GITHUB_EVENT_NAME" == "issue_comment" ]]; then - # On-demand PR-head snapshot: encode the PR number so concurrent PRs never collide on the `next` dist-tag. printf '%s-PR%s-SNAPSHOT.%s' "$1" "$PR_NUMBER" "$GITHUB_RUN_NUMBER" else - # Trusted release-line snapshot: run number + attempt keep every re-run monotonic and unique. printf '%s-SNAPSHOT.%s.%s' "$1" "$GITHUB_RUN_NUMBER" "$GITHUB_RUN_ATTEMPT" fi } - member_dir() { # -> flat workspaces member directory + member_dir() { printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' } mkdir -p "$DIST_DIR" - # Install, build, stamp the run-unique snapshot version per unit (no lifecycle scripts), then pack the pre-built tree; npm packs workspaces in one call while pnpm packs each member from its own dir, and `npm pkg set` stamps versions in both paths without touching any lockfile. case "${PACKAGE_MANAGER:-npm}" in npm) npm ci @@ -217,7 +210,6 @@ jobs: esac ;; pnpm) - # Corepack activates the exact pnpm pinned in the product's package.json "packageManager" field: no floating pnpm, no product edit. corepack enable pnpm install --frozen-lockfile pnpm run build @@ -259,11 +251,9 @@ jobs: if-no-files-found: error retention-days: 5 - # Publish stage: no candidate code runs here. The privileged npm publish is INLINE in this top-level file (so OIDC job_workflow_ref == the registered workflow_ref), granting id-token: write only at this reviewer-gated npm-snapshot boundary to ship the pre-built tarball. publish: name: Publish snapshot needs: [build] - # Deploy is gated on the descriptor snapshot opt-in (belt-and-suspenders with the Plan step's should_publish): the credential-bearing deploy never runs unless publish_snapshot is enabled. if: >- !cancelled() && needs.build.outputs.should_publish == 'true' && @@ -273,13 +263,9 @@ jobs: environment: npm-snapshot permissions: contents: read - id-token: write # The npm trusted-publishing OIDC boundary, inline in the registered top-level file. - env: - # Environment-scoped fallback for a package not yet a registered trusted publisher; set at JOB level so the composite's run steps inherit it, and unreachable from the build stage. - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + id-token: write steps: - name: Check out trusted publish ref - # Its own trusted ref, never the candidate head; the reviewed bytes ship as the pre-built artifact only. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -291,27 +277,24 @@ jobs: path: ${{ runner.temp }}/publish-dist - name: Publish via npm trusted publishing - # The composite publishes each pre-built tarball with --ignore-scripts, sets the dist-tag, enables provenance only when the repository is public, and falls back to NPM_TOKEN for an unregistered package. - uses: InditexTech/gh-actions/npm@5159a0213e29a091bf732553681a0de8ac712d17 # gh-actions/npm composite @ main; no release tag yet + uses: InditexTech/gh-actions/npm@977030536dbdb52a7fe2fd5979510fd6a225d035 # v1.1.0 + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} with: working-directory: ${{ vars.WORKING_DIRECTORY }} project-type: ${{ vars.PROJECT_TYPE }} dist-tag: next artifact-directory: ${{ runner.temp }}/publish-dist - # NPM_TOKEN is provided at job level: a composite's run steps do not inherit a caller step's env. - - # Release lane (jobs: release-core -> publish-npm -> github-release). release-core: name: Prepare and Build Release - # Lane routing: the release cut runs only on release triggers, never on a push / comment (those drive the snapshot lane above). if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' uses: ./.github/workflows/code-npm_node-release-core.yml with: release_type: ${{ inputs.release_type }} - secrets: inherit + secrets: + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} - # Govern-inject publish path: release-core packed the tarballs and THIS top-level file publishes them INLINE (so OIDC job_workflow_ref == the registered workflow_ref) through the pinned gh-actions/npm composite, holding id-token: write only in this reviewer-gated boundary; skipped in delegated mode. publish-npm: name: Publish ${{ matrix.release.tag }} to npm needs: release-core @@ -321,17 +304,13 @@ jobs: environment: npm-registry permissions: contents: read - id-token: write # The npm trusted-publishing OIDC boundary, inline in the registered top-level file. + id-token: write strategy: fail-fast: false matrix: release: ${{ fromJSON(needs.release-core.outputs.releases) }} - env: - # Environment-scoped fallback for a package not yet a registered trusted publisher; set at JOB level so the composite's run steps inherit it. - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - name: Check out trusted publish ref - # Its own trusted ref, never a candidate head; the release bytes ship as the pre-built artifact only. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -343,37 +322,286 @@ jobs: path: ${{ runner.temp }}/publish-dist - name: Publish via npm trusted publishing - # The composite publishes each pre-built tarball with --ignore-scripts, sets the dist-tag, enables provenance only when the repository is public, and falls back to NPM_TOKEN for an unregistered package. - uses: InditexTech/gh-actions/npm@5159a0213e29a091bf732553681a0de8ac712d17 # gh-actions/npm composite @ main; no release tag yet + uses: InditexTech/gh-actions/npm@977030536dbdb52a7fe2fd5979510fd6a225d035 # v1.1.0 + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} with: working-directory: ${{ vars.WORKING_DIRECTORY }} project-type: ${{ vars.PROJECT_TYPE }} dist-tag: latest artifact-directory: ${{ runner.temp }}/publish-dist - # NPM_TOKEN is provided at job level: a composite's run steps do not inherit a caller step's env. - # Delegated publish path: the project's own release:perform runs under the reviewer-gated npm-registry Environment with the ENV-TOKEN (NPM_TOKEN), NEVER id-token, and is trusted-trigger-only so it carries no pwn-request surface; the inline publish-npm job above stays the SOLE id-token boundary, preserving the single-registrable-entrypoint invariant. publish-npm-delegated: - name: Publish delegated release to npm + name: Promote and publish delegated release to npm needs: release-core if: >- vars.RELEASE_LIFECYCLE == 'delegated' && + needs.release-core.result == 'success' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') runs-on: ubuntu-24.04 timeout-minutes: 30 environment: npm-registry permissions: contents: read + id-token: write steps: - - name: Check out release commit + - name: Download immutable delegated release handoff + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: node-delegated-release-handoff + path: ${{ runner.temp }}/node-delegated-release-handoff + + - name: Verify delegated release handoff + id: handoff + env: + HANDOFF_DIR: ${{ runner.temp }}/node-delegated-release-handoff + shell: bash + run: | + set -euo pipefail + metadata="$HANDOFF_DIR/release-handoff.json" + source_bundle="$HANDOFF_DIR/release-source.bundle" + publish_dist="${RUNNER_TEMP}/publish-dist" + if [[ ! -f "$metadata" || ! -f "$source_bundle" || ! -d "$HANDOFF_DIR/tarballs" ]]; then + echo "::error title=Incomplete release handoff::The immutable source, metadata, and tarball directory are all required." + exit 1 + fi + if [[ "$(sha256sum "$metadata" | awk '{print $1}')" != "${{ needs.release-core.outputs.handoff_digest }}" ]]; then + echo "::error title=Tampered release handoff::The run-bound release metadata digest does not match preparation." + exit 1 + fi + jq -e \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg run_id "$GITHUB_RUN_ID" \ + --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + ' + .schema_version == 1 and + .repository == $repository and + .run_id == $run_id and + .run_attempt == $run_attempt and + (.expected_ref | test("^refs/heads/[A-Za-z0-9._/-]+$")) and + (.source_commit | test("^[0-9a-f]{40}$")) and + (.release_commit | test("^[0-9a-f]{40}$")) and + (.source_bundle.file == "release-source.bundle") and + (.source_bundle.sha256 | test("^[0-9a-f]{64}$")) and + (.releases | type == "array" and length > 0) and + (.tags | type == "array" and length > 0) and + (.tarballs | type == "array" and length > 0) + ' "$metadata" > /dev/null + if [[ "$(sha256sum "$source_bundle" | awk '{print $1}')" != "$(jq -r '.source_bundle.sha256' "$metadata")" ]]; then + echo "::error title=Tampered release handoff::The release source bundle digest does not match metadata." + exit 1 + fi + jq -e ' + (.tags | all(.[]; (.name | type == "string" and length > 0) and (.target | type == "string" and test("^[0-9a-f]{40}$")))) and + (.releases | all(.[]; (.tag | type == "string" and length > 0) and (.version | type == "string" and length > 0))) and + (([.tags[].name] | sort) == ([.releases[].tag] | sort)) and + (.tarballs | all(.[]; (.file | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*\\.tgz$")) and (.package | type == "string" and length > 0) and (.release_tag | type == "string" and length > 0) and (.version | type == "string" and length > 0) and (.sha256 | type == "string" and test("^[0-9a-f]{64}$")))) + ' "$metadata" > /dev/null + + release_tag_for() { + local package_name="$1" + local package_version="$2" + local member record release_tag release_version + local -a exact_matches=() + local -a version_matches=() + member="$(printf '%s' "${package_name##*/}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + while IFS= read -r record; do + release_tag="$(jq -r '.tag' <<< "$record")" + release_version="$(jq -r '.version' <<< "$record")" + [[ "$release_version" == "$package_version" ]] || continue + version_matches+=("$release_tag") + if [[ "$release_tag" == "$package_version" || "$release_tag" == "${member}-${package_version}" || "$release_tag" == "${package_name}-${package_version}" ]]; then + exact_matches+=("$release_tag") + fi + done < <(jq -c '.releases[]' "$metadata") + if [[ ${#exact_matches[@]} -eq 1 ]]; then + printf '%s' "${exact_matches[0]}" + elif [[ ${#exact_matches[@]} -eq 0 && ${#version_matches[@]} -eq 1 ]]; then + printf '%s' "${version_matches[0]}" + else + echo "::error title=Ambiguous package release::Package '$package_name@$package_version' does not resolve to exactly one prepared release tag." >&2 + exit 1 + fi + } + + rm -rf "$HANDOFF_DIR/source" "$publish_dist" + git clone --quiet --no-checkout "$source_bundle" "$HANDOFF_DIR/source" + source_commit="$(jq -r '.source_commit' "$metadata")" + release_commit="$(jq -r '.release_commit' "$metadata")" + git -C "$HANDOFF_DIR/source" rev-parse --verify --quiet "${source_commit}^{commit}" > /dev/null + git -C "$HANDOFF_DIR/source" rev-parse --verify --quiet "${release_commit}^{commit}" > /dev/null + if ! git -C "$HANDOFF_DIR/source" merge-base --is-ancestor "$source_commit" "$release_commit"; then + echo "::error title=Invalid release provenance::The prepared release is not descended from the verified source commit." + exit 1 + fi + + tag_count="$(jq -r '.tags | length' "$metadata")" + unique_tag_count="$(jq -r '[.tags[].name] | unique | length' "$metadata")" + if [[ "$unique_tag_count" -ne "$tag_count" ]]; then + echo "::error title=Invalid release tags::The handoff repeats a release tag." + exit 1 + fi + while IFS= read -r tag; do + git check-ref-format --allow-onelevel "refs/tags/$tag" + target="$(jq -er --arg tag "$tag" '.tags[] | select(.name == $tag) | .target | select(test("^[0-9a-f]{40}$"))' "$metadata")" + if [[ "$(git -C "$HANDOFF_DIR/source" rev-parse "${tag}^{commit}")" != "$target" ]]; then + echo "::error title=Invalid release tag::Prepared tag '$tag' does not resolve to its recorded commit." + exit 1 + fi + if ! git -C "$HANDOFF_DIR/source" merge-base --is-ancestor "$source_commit" "$target" \ + || ! git -C "$HANDOFF_DIR/source" merge-base --is-ancestor "$target" "$release_commit"; then + echo "::error title=Invalid release tag provenance::Prepared tag '$tag' must satisfy source <= tag <= release." + exit 1 + fi + done < <(jq -r '.tags[].name' "$metadata") + + mkdir -p "$publish_dist" + tarball_count="$(jq -r '.tarballs | length' "$metadata")" + unique_tarball_count="$(jq -r '[.tarballs[].file] | unique | length' "$metadata")" + if [[ "$unique_tarball_count" -ne "$tarball_count" ]]; then + echo "::error title=Invalid package artifacts::The handoff repeats a tarball filename." + exit 1 + fi + while IFS= read -r tarball_file; do + tarball="$HANDOFF_DIR/tarballs/$tarball_file" + expected_sha="$(jq -er --arg file "$tarball_file" '.tarballs[] | select(.file == $file) | .sha256' "$metadata")" + expected_package="$(jq -er --arg file "$tarball_file" '.tarballs[] | select(.file == $file) | .package' "$metadata")" + expected_release_tag="$(jq -er --arg file "$tarball_file" '.tarballs[] | select(.file == $file) | .release_tag' "$metadata")" + expected_version="$(jq -er --arg file "$tarball_file" '.tarballs[] | select(.file == $file) | .version' "$metadata")" + if [[ ! -f "$tarball" ]] || [[ "$(sha256sum "$tarball" | awk '{print $1}')" != "$expected_sha" ]]; then + echo "::error title=Tampered package artifact::Tarball '$tarball_file' is missing or does not match the prepared digest." + exit 1 + fi + tar -tzf "$tarball" | grep -qx 'package/package.json' + if [[ "$(tar -xzOf "$tarball" package/package.json | jq -r '.name')" != "$expected_package" ]] \ + || [[ "$(tar -xzOf "$tarball" package/package.json | jq -r '.version')" != "$expected_version" ]]; then + echo "::error title=Tampered package artifact::Tarball '$tarball_file' package identity does not match the prepared metadata." + exit 1 + fi + mapped_release_tag="$(release_tag_for "$expected_package" "$expected_version")" + if [[ "$mapped_release_tag" != "$expected_release_tag" ]] \ + || [[ "$(jq -r --arg tag "$expected_release_tag" --arg version "$expected_version" '[.releases[] | select(.tag == $tag and .version == $version)] | length' "$metadata")" -ne 1 ]]; then + echo "::error title=Invalid package release::Tarball '$expected_package@$expected_version' is not bound to exactly one prepared release tag." + exit 1 + fi + cp "$tarball" "$publish_dist/$tarball_file" + done < <(jq -r '.tarballs[].file' "$metadata") + if [[ "$(find "$publish_dist" -maxdepth 1 -type f -name '*.tgz' | wc -l | tr -d ' ')" -ne "$tarball_count" ]]; then + echo "::error title=Incomplete package artifacts::The protected publish directory does not contain the complete verified tarball set." + exit 1 + fi + + { + echo "expected_ref=$(jq -r '.expected_ref' "$metadata")" + echo "release_commit=$release_commit" + echo "source_commit=$source_commit" + } >> "$GITHUB_OUTPUT" + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + + - name: Check out trusted release baseline uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false - ref: ${{ needs.release-core.outputs.release_commit }} + ref: ${{ github.event.pull_request.base.ref || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Promote verified delegated release refs + env: + EXPECTED_REF: ${{ steps.handoff.outputs.expected_ref }} + HANDOFF_DIR: ${{ runner.temp }}/node-delegated-release-handoff + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASE_COMMIT: ${{ steps.handoff.outputs.release_commit }} + SOURCE_COMMIT: ${{ steps.handoff.outputs.source_commit }} + shell: bash + run: | + set -euo pipefail + gh auth setup-git + source="$HANDOFF_DIR/source" + git -C "$source" remote set-url origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + if [[ "$(git ls-remote origin "$EXPECTED_REF" | cut -f1)" != "$SOURCE_COMMIT" ]]; then + echo "::error title=Release baseline moved::The protected release branch no longer points to the verified source commit." + exit 1 + fi + tag_refs=() + while IFS= read -r tag; do + if git ls-remote --exit-code --tags origin "refs/tags/$tag" > /dev/null 2>&1; then + echo "::error title=Tag conflict::Release tag '$tag' already exists on the remote." + exit 1 + fi + tag_refs+=("refs/tags/$tag") + done < <(jq -r '.tags[].name' "$HANDOFF_DIR/release-handoff.json") + git -C "$source" push --atomic origin "$RELEASE_COMMIT:$EXPECTED_REF" "${tag_refs[@]}" + + - name: Publish verified delegated tarballs via npm trusted publishing + uses: InditexTech/gh-actions/npm@977030536dbdb52a7fe2fd5979510fd6a225d035 # v1.1.0 + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + with: + working-directory: ${{ vars.WORKING_DIRECTORY }} + project-type: ${{ vars.PROJECT_TYPE }} + dist-tag: latest + artifact-directory: ${{ runner.temp }}/publish-dist + + prepare-delegated-next-development: + name: Prepare delegated next-development source + needs: [release-core, publish-npm-delegated] + if: >- + vars.RELEASE_LIFECYCLE == 'delegated' && + needs.release-core.result == 'success' && + needs.publish-npm-delegated.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + outputs: + handoff_digest: ${{ steps.next-development.outputs.handoff_digest }} + steps: + - name: Download immutable delegated release handoff + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: node-delegated-release-handoff + path: ${{ runner.temp }}/node-delegated-release-handoff + + - name: Restore verified release source + id: source + env: + HANDOFF_DIGEST: ${{ needs.release-core.outputs.handoff_digest }} + HANDOFF_DIR: ${{ runner.temp }}/node-delegated-release-handoff + shell: bash + run: | + set -euo pipefail + metadata="$HANDOFF_DIR/release-handoff.json" + source_bundle="$HANDOFF_DIR/release-source.bundle" + if [[ ! -f "$metadata" || ! -f "$source_bundle" ]] \ + || [[ "$(sha256sum "$metadata" | awk '{print $1}')" != "$HANDOFF_DIGEST" ]] \ + || [[ "$(sha256sum "$source_bundle" | awk '{print $1}')" != "$(jq -r '.source_bundle.sha256' "$metadata")" ]]; then + echo "::error title=Tampered release handoff::Next-development preparation requires the original verified release handoff." + exit 1 + fi + jq -e \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg run_id "$GITHUB_RUN_ID" \ + --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + '.schema_version == 1 and .repository == $repository and .run_id == $run_id and .run_attempt == $run_attempt and (.release_commit | test("^[0-9a-f]{40}$"))' \ + "$metadata" > /dev/null + rm -rf "$HANDOFF_DIR/source" + git clone --quiet --no-checkout "$source_bundle" "$HANDOFF_DIR/source" + release_commit="$(jq -r '.release_commit' "$metadata")" + git -C "$HANDOFF_DIR/source" checkout --quiet --detach "$release_commit" + echo "release_commit=$release_commit" >> "$GITHUB_OUTPUT" + echo "source_root=$HANDOFF_DIR/source" >> "$GITHUB_OUTPUT" - name: Read governed tool versions id: tool-versions - working-directory: ${{ env.WORKING_DIRECTORY }} + working-directory: ${{ steps.source.outputs.source_root }}/${{ env.WORKING_DIRECTORY }} shell: bash run: | set -euo pipefail @@ -389,44 +617,205 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Set up asdf-managed Node - uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.0 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 with: tool_versions: ${{ steps.tool-versions.outputs.tool_versions }} asdf_version: ${{ env.ASDF_BRANCH_VERSION }} - - name: Install dependencies - # No publish credential in scope here, so dependency install scripts never see NPM_TOKEN; the token is added only to the release:perform step below. - working-directory: ${{ env.WORKING_DIRECTORY }} + - name: Prepare next development source + env: + RELEASES: ${{ needs.release-core.outputs.releases }} + SOURCE_ROOT: ${{ steps.source.outputs.source_root }} + working-directory: ${{ steps.source.outputs.source_root }}/${{ env.WORKING_DIRECTORY }} shell: bash run: | set -euo pipefail - # Package manager defaults to npm; pnpm is provisioned via corepack from the product's packageManager pin. + git -C "$SOURCE_ROOT" config user.name "github-actions[bot]" + git -C "$SOURCE_ROOT" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + release_bump="$(jq -er '.[0].release_bump | select(type == "string" and length > 0)' <<< "$RELEASES")" case "${PACKAGE_MANAGER:-npm}" in - npm) npm ci ;; - pnpm) corepack enable; pnpm install --frozen-lockfile ;; + npm) RELEASE_BUMP="$release_bump" npm run version:development ;; + pnpm) corepack enable; RELEASE_BUMP="$release_bump" pnpm run version:development ;; *) echo "::error title=Invalid package manager::PACKAGE_MANAGER must be npm or pnpm."; exit 1 ;; esac + git -C "$SOURCE_ROOT" add --update -- "$WORKING_DIRECTORY" + if git -C "$SOURCE_ROOT" diff --cached --quiet; then + echo "::error title=Missing next-development update::version:development did not produce a source change." + exit 1 + fi + git -C "$SOURCE_ROOT" commit -m "[node-release] prepare for next development iteration" - - name: Run project release:perform - working-directory: ${{ env.WORKING_DIRECTORY }} + - name: Create next-development handoff + id: next-development env: - # Reviewer-gated Environment secret and the sole publish credential in the delegated lane; no id-token is minted here. - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + HANDOFF_DIGEST: ${{ needs.release-core.outputs.handoff_digest }} + HANDOFF_DIR: ${{ runner.temp }}/node-delegated-release-handoff + RELEASE_COMMIT: ${{ steps.source.outputs.release_commit }} + SOURCE_ROOT: ${{ steps.source.outputs.source_root }} shell: bash run: | set -euo pipefail - # The release commit already passed verify and its tags are pushed; the project's release:perform publishes the tagged versions through its own package manager (corepack enabled above) and owns the publish shape. - case "${PACKAGE_MANAGER:-npm}" in - npm) npm run release:perform ;; - pnpm) pnpm run release:perform ;; - *) echo "::error title=Invalid package manager::PACKAGE_MANAGER must be npm or pnpm."; exit 1 ;; - esac + next_development_commit="$(git -C "$SOURCE_ROOT" rev-parse HEAD)" + if ! git -C "$SOURCE_ROOT" merge-base --is-ancestor "$RELEASE_COMMIT" "$next_development_commit"; then + echo "::error title=Invalid next-development provenance::The next-development commit is not descended from the published release." + exit 1 + fi + git -C "$SOURCE_ROOT" bundle create "$HANDOFF_DIR/next-development.bundle" "$next_development_commit" + bundle_sha256="$(sha256sum "$HANDOFF_DIR/next-development.bundle" | awk '{print $1}')" + jq -n \ + --arg bundle_sha256 "$bundle_sha256" \ + --arg handoff_digest "$HANDOFF_DIGEST" \ + --arg next_development_commit "$next_development_commit" \ + --arg release_commit "$RELEASE_COMMIT" \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg run_id "$GITHUB_RUN_ID" \ + '{ + schema_version: 1, + repository: $repository, + run_id: $run_id, + run_attempt: $run_attempt, + release_handoff_digest: $handoff_digest, + release_commit: $release_commit, + next_development_commit: $next_development_commit, + source_bundle: {file: "next-development.bundle", sha256: $bundle_sha256} + }' > "$HANDOFF_DIR/next-development-handoff.json" + echo "handoff_digest=$(sha256sum "$HANDOFF_DIR/next-development-handoff.json" | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Upload next-development handoff + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: node-delegated-next-development-handoff + path: | + ${{ runner.temp }}/node-delegated-release-handoff/next-development.bundle + ${{ runner.temp }}/node-delegated-release-handoff/next-development-handoff.json + if-no-files-found: error + retention-days: 14 + + finalize-delegated-release: + name: Finalize delegated release (next dev + sync PR) + needs: [release-core, publish-npm-delegated, prepare-delegated-next-development] + if: >- + vars.RELEASE_LIFECYCLE == 'delegated' && + needs.release-core.result == 'success' && + needs.publish-npm-delegated.result == 'success' && + needs.prepare-delegated-next-development.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Check out release branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.base.ref || github.ref_name }} + token: ${{ steps.app-token.outputs.token }} + + - name: Download immutable next-development handoff + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: node-delegated-next-development-handoff + path: ${{ runner.temp }}/node-delegated-next-development-handoff + + - name: Verify and promote next development source + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + BASELINE_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }} + DEVELOPMENT_FLOW: ${{ vars.DEVELOPMENT_FLOW }} + EXPECTED_HANDOFF_DIGEST: ${{ needs.prepare-delegated-next-development.outputs.handoff_digest }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + HANDOFF_DIR: ${{ runner.temp }}/node-delegated-next-development-handoff + RELEASE_HANDOFF_DIGEST: ${{ needs.release-core.outputs.handoff_digest }} + RELEASES: ${{ needs.release-core.outputs.releases }} + RELEASE_COMMIT: ${{ needs.release-core.outputs.release_commit }} + shell: bash + run: | + set -euo pipefail + metadata="$HANDOFF_DIR/next-development-handoff.json" + source_bundle="$HANDOFF_DIR/next-development.bundle" + if [[ ! -f "$metadata" || ! -f "$source_bundle" ]] \ + || [[ "$(sha256sum "$metadata" | awk '{print $1}')" != "$EXPECTED_HANDOFF_DIGEST" ]]; then + echo "::error title=Tampered next-development handoff::The run-bound next-development metadata is missing or changed." + exit 1 + fi + jq -e \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg run_id "$GITHUB_RUN_ID" \ + --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg release_commit "$RELEASE_COMMIT" \ + --arg release_handoff_digest "$RELEASE_HANDOFF_DIGEST" \ + ' + .schema_version == 1 and + .repository == $repository and + .run_id == $run_id and + .run_attempt == $run_attempt and + .release_commit == $release_commit and + .release_handoff_digest == $release_handoff_digest and + (.next_development_commit | test("^[0-9a-f]{40}$")) and + (.source_bundle.file == "next-development.bundle") and + (.source_bundle.sha256 | test("^[0-9a-f]{64}$")) + ' "$metadata" > /dev/null + if [[ "$(sha256sum "$source_bundle" | awk '{print $1}')" != "$(jq -r '.source_bundle.sha256' "$metadata")" ]]; then + echo "::error title=Tampered next-development handoff::The next-development source bundle digest does not match metadata." + exit 1 + fi + + rm -rf "$HANDOFF_DIR/source" + git clone --quiet --no-checkout "$source_bundle" "$HANDOFF_DIR/source" + source="$HANDOFF_DIR/source" + next_development_commit="$(jq -r '.next_development_commit' "$metadata")" + git -C "$source" rev-parse --verify --quiet "${next_development_commit}^{commit}" > /dev/null + if ! git -C "$source" merge-base --is-ancestor "$RELEASE_COMMIT" "$next_development_commit"; then + echo "::error title=Invalid next-development provenance::The next-development source is not descended from the published release." + exit 1 + fi + + gh auth setup-git + git -C "$source" remote set-url origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + expected_ref="refs/heads/$BASELINE_BRANCH" + if [[ "$(git ls-remote origin "$expected_ref" | cut -f1)" != "$RELEASE_COMMIT" ]]; then + echo "::error title=Release baseline moved::The protected release branch no longer points to the published release." + exit 1 + fi + git -C "$source" push origin "$next_development_commit:$expected_ref" + + if [[ "$DEVELOPMENT_FLOW" != "git-flow" ]]; then + echo "::notice title=Sync skipped::Sync-to-develop applies only to git-flow." + exit 0 + fi + + version="$(jq -r '.[0].version' <<< "$RELEASES")" + develop_branch="${BASELINE_BRANCH/main/develop}" + if [[ -z "$(git ls-remote --heads origin "$develop_branch")" ]]; then + echo "::notice title=Sync skipped::$develop_branch does not exist." + exit 0 + fi + sync_branch="automated/sync-release-${version}-to-${develop_branch}" + git fetch --no-tags origin "$BASELINE_BRANCH" + git switch --force-create "$sync_branch" "origin/$BASELINE_BRANCH" + git push --force-with-lease --set-upstream origin "$sync_branch" + + if [[ -z "$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$sync_branch" --base "$develop_branch" --state open --json number --jq '.[0].number // empty')" ]]; then + gh pr create --repo "$GITHUB_REPOSITORY" \ + --base "$develop_branch" --head "$sync_branch" \ + --title "Sync release $version to $develop_branch" \ + --body "**Automated pull request** syncing the release cut on \`$BASELINE_BRANCH\` back into \`$develop_branch\`." + fi github-release: name: Create GitHub Release ${{ matrix.release.tag }} needs: [release-core, publish-npm, publish-npm-delegated] - # Tolerate whichever publish path was skipped for the active lifecycle. if: >- !cancelled() && needs.release-core.result == 'success' && @@ -450,7 +839,6 @@ jobs: permission-contents: write - name: Download release distributions - # Govern-inject only: the engine packed tarballs to attach; in delegated mode release:perform published directly, so the Release carries generated notes only. if: vars.RELEASE_LIFECYCLE != 'delegated' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -458,7 +846,6 @@ jobs: path: dist - name: Create or update GitHub Release - # release-core already created and pushed the annotated tag; this attaches the packed tarballs in govern-inject and is notes-only in delegated, with --clobber keeping re-runs idempotent. env: GH_TOKEN: ${{ steps.app-token.outputs.token }} RELEASE_TAG: ${{ matrix.release.tag }} diff --git a/.github/workflows/code-npm_node-release-core.yml b/.github/workflows/code-npm_node-release-core.yml index 3783033..8266cf6 100644 --- a/.github/workflows/code-npm_node-release-core.yml +++ b/.github/workflows/code-npm_node-release-core.yml @@ -13,24 +13,31 @@ on: default: '' outputs: release_commit: - description: Immutable commit used to build the release artifacts. + description: Immutable commit represented by the release handoff. value: ${{ jobs.prepare-release.outputs.release_commit }} releases: description: JSON array of release tags, versions, package lists, and next-dev versions. value: ${{ jobs.prepare-release.outputs.releases }} + handoff_digest: + description: SHA-256 of the run-bound delegated release metadata, or empty outside delegated lifecycle. + value: ${{ jobs.prepare-release.outputs.handoff_digest }} + secrets: + APP_PRIVATE_KEY: + required: true permissions: contents: read env: + CI_GOVERNANCE_RELEASE_STAGING_REF: refs/ci-governance/release/${{ github.run_id }}-${{ github.run_attempt }} PROJECT_TYPE: ${{ vars.PROJECT_TYPE }} WORKING_DIRECTORY: ${{ vars.WORKING_DIRECTORY }} PACKAGE_MANAGER: ${{ vars.PACKAGE_MANAGER }} ASDF_BRANCH_VERSION: '0.18.0' jobs: - prepare-release: - name: Prepare Release + verify-candidate: + name: Verify release candidate if: >- ( github.event_name == 'workflow_dispatch' && @@ -42,6 +49,7 @@ jobs: ( github.event_name == 'pull_request' && github.event.pull_request.merged && + github.event.pull_request.head.ref != 'automated/ci-governance-sync' && !contains(join(github.event.pull_request.labels.*.name, ','), 'skip-release') && ( (vars.DEVELOPMENT_FLOW == 'trunk-based-development' && (github.event.pull_request.base.ref == 'main' || startsWith(github.event.pull_request.base.ref, 'main-'))) || @@ -54,9 +62,121 @@ jobs: ) runs-on: ubuntu-24.04 timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Resolve release context + id: release-context + env: + EVENT_SHA: ${{ github.sha }} + PR_MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + release_ref="$EVENT_SHA" + else + release_ref="$PR_MERGE_SHA" + fi + if [[ ! "$release_ref" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error title=Invalid release context::An immutable release commit is required." + exit 1 + fi + echo "release_ref=$release_ref" >> "$GITHUB_OUTPUT" + + - name: Check out release candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ steps.release-context.outputs.release_ref }} + + - name: Read governed tool versions + id: tool-versions + working-directory: ${{ env.WORKING_DIRECTORY }} + shell: bash + run: | + set -euo pipefail + if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' .tool-versions; then + echo "::error title=Invalid tool-versions::${WORKING_DIRECTORY}/.tool-versions is malformed." + exit 1 + fi + { + echo "tool_versions<> "$GITHUB_OUTPUT" + + - name: Set up asdf-managed Node + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 + with: + tool_versions: ${{ steps.tool-versions.outputs.tool_versions }} + asdf_version: ${{ env.ASDF_BRANCH_VERSION }} + + - name: Verify release candidate + working-directory: ${{ env.WORKING_DIRECTORY }} + shell: bash + run: | + set -euo pipefail + case "$PROJECT_TYPE" in + single|workspaces) : ;; + *) + echo "::error title=Invalid project type::PROJECT_TYPE must be single or workspaces." + exit 1 + ;; + esac + case "${PACKAGE_MANAGER:-npm}" in + npm) + npm ci + npm run verify + ;; + pnpm) + corepack enable + pnpm install --frozen-lockfile + pnpm run verify + ;; + *) + echo "::error title=Invalid package manager::PACKAGE_MANAGER must be npm or pnpm." + exit 1 + ;; + esac + + prepare-release: + name: Prepare Release + needs: verify-candidate + if: >- + needs.verify-candidate.result == 'success' && + ( + ( + github.event_name == 'workflow_dispatch' && + ( + (vars.DEVELOPMENT_FLOW == 'trunk-based-development' && (github.ref_name == 'main' || startsWith(github.ref_name, 'main-'))) || + (vars.DEVELOPMENT_FLOW == 'git-flow' && (github.ref_name == 'main' || startsWith(github.ref_name, 'main-'))) + ) + ) || + ( + github.event_name == 'pull_request' && + github.event.pull_request.merged && + github.event.pull_request.head.ref != 'automated/ci-governance-sync' && + !contains(join(github.event.pull_request.labels.*.name, ','), 'skip-release') && + ( + (vars.DEVELOPMENT_FLOW == 'trunk-based-development' && (github.event.pull_request.base.ref == 'main' || startsWith(github.event.pull_request.base.ref, 'main-'))) || + (vars.DEVELOPMENT_FLOW == 'git-flow' && (github.event.pull_request.base.ref == 'main' || startsWith(github.event.pull_request.base.ref, 'main-'))) + ) && + ( + contains(join(github.event.pull_request.labels.*.name, ','), 'release-type') || + vars.DEVELOPMENT_FLOW == 'trunk-based-development' + ) + ) + ) + runs-on: ubuntu-24.04 + timeout-minutes: 30 outputs: release_commit: ${{ steps.release-metadata.outputs.release_commit }} releases: ${{ steps.release-metadata.outputs.releases }} + expected_ref: ${{ steps.release-plan.outputs.expected_ref || steps.delegated-plan.outputs.expected_ref }} + staging_ref: ${{ steps.release-metadata.outputs.staging_ref }} + handoff_digest: ${{ steps.delegated-handoff.outputs.handoff_digest }} steps: - name: Resolve release context id: release-context @@ -91,8 +211,22 @@ jobs: echo "release_ref=$release_ref" } >> "$GITHUB_OUTPUT" + - name: Enforce protected release branch + env: + GH_TOKEN: ${{ github.token }} + BASELINE_BRANCH: ${{ steps.release-context.outputs.baseline_branch }} + shell: bash + run: | + set -euo pipefail + protected="$(gh api "repos/${GITHUB_REPOSITORY}/branches/${BASELINE_BRANCH}" --jq '.protected')" + if [[ "$protected" != "true" ]]; then + echo "::error title=Unprotected release branch::Releases require a protected baseline branch; '${BASELINE_BRANCH}' is not protected." >&2 + exit 1 + fi + - name: Create GitHub App token id: app-token + if: vars.RELEASE_LIFECYCLE != 'delegated' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.APP_CLIENT_ID }} @@ -106,7 +240,6 @@ jobs: fetch-depth: 0 persist-credentials: false ref: ${{ steps.release-context.outputs.release_ref }} - token: ${{ steps.app-token.outputs.token }} - name: Read governed tool versions id: tool-versions @@ -126,37 +259,21 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Set up asdf-managed Node - uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.0 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 with: tool_versions: ${{ steps.tool-versions.outputs.tool_versions }} asdf_version: ${{ env.ASDF_BRANCH_VERSION }} - - name: Verify release candidate + - name: Install dependencies for delegated release + if: vars.RELEASE_LIFECYCLE == 'delegated' working-directory: ${{ env.WORKING_DIRECTORY }} shell: bash run: | set -euo pipefail - case "$PROJECT_TYPE" in - single|workspaces) : ;; - *) - echo "::error title=Invalid project type::PROJECT_TYPE must be single or workspaces." - exit 1 - ;; - esac case "${PACKAGE_MANAGER:-npm}" in - npm) - npm ci - npm run verify - ;; - pnpm) - corepack enable - pnpm install --frozen-lockfile - pnpm run verify - ;; - *) - echo "::error title=Invalid package manager::PACKAGE_MANAGER must be npm or pnpm." - exit 1 - ;; + npm) npm ci ;; + pnpm) corepack enable; pnpm install --frozen-lockfile ;; + *) echo "::error title=Invalid package manager::PACKAGE_MANAGER must be npm or pnpm."; exit 1 ;; esac - name: Plan release metadata @@ -231,7 +348,7 @@ jobs: jq -r '.version' package.json fi } - bump_semver() { # + bump_semver() { local major minor patch IFS=. read -r major minor patch <<< "$1" case "$2" in @@ -334,7 +451,6 @@ jobs: set_version "$(jq -r '.[0].version' <<< "$releases")" "" fi - # Reconcile workspace cross-dependency specs to each bumped sibling's released version and regenerate the lockfile so published metadata resolves the bumped siblings, not the pre-release -SNAPSHOT specs (needs bash>=4 declare -A; ubuntu runner). if [[ "$PROJECT_TYPE" == "workspaces" ]]; then declare -A member_version=() while IFS= read -r record; do @@ -438,7 +554,7 @@ jobs: rm -f "$tmp" fi - - name: Commit, tag and push release + - name: Commit and stage release if: vars.RELEASE_LIFECYCLE != 'delegated' && steps.release-plan.outputs.resume != 'true' id: release-commit env: @@ -460,34 +576,27 @@ jobs: git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" git commit -m "[node-release] Prepare release" - tag_refs=() while IFS= read -r record; do - tag="$(jq -r '.tag' <<< "$record")" - version="$(jq -r '.version' <<< "$record")" - git check-ref-format --allow-onelevel "refs/tags/$tag" - git tag -a "$tag" -m "Release $version" - tag_refs+=("refs/tags/$tag") + git check-ref-format --allow-onelevel "refs/tags/$(jq -r '.tag' <<< "$record")" done < <(jq -c '.[]' <<< "$RELEASES") - git push --atomic origin "HEAD:$EXPECTED_REF" "${tag_refs[@]}" + git push --atomic --force origin "HEAD:$CI_GOVERNANCE_RELEASE_STAGING_REF" - name: Cut delegated release id: delegated-plan if: vars.RELEASE_LIFECYCLE == 'delegated' env: - APP_SLUG: ${{ steps.app-token.outputs.app-slug }} BASELINE_BRANCH: ${{ steps.release-context.outputs.baseline_branch }} DEVELOPMENT_FLOW: ${{ vars.DEVELOPMENT_FLOW }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} RELEASE_LABELS: ${{ steps.release-context.outputs.release_labels }} + RELEASE_SOURCE_COMMIT: ${{ steps.release-context.outputs.release_ref }} working-directory: ${{ env.WORKING_DIRECTORY }} shell: bash run: | set -euo pipefail - gh auth setup-git - git config user.name "${APP_SLUG}[bot]" - git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" case ",$RELEASE_LABELS," in *",release-type/major,"*) release_bump="major" ;; @@ -515,7 +624,10 @@ jobs: esac git tag | sort > "$RUNNER_TEMP/tags-after.txt" - mapfile -t new_tags < <(comm -13 "$RUNNER_TEMP/tags-before.txt" "$RUNNER_TEMP/tags-after.txt") + new_tags=() + while IFS= read -r tag; do + new_tags+=("$tag") + done < <(comm -13 "$RUNNER_TEMP/tags-before.txt" "$RUNNER_TEMP/tags-after.txt") if [[ ${#new_tags[@]} -eq 0 ]]; then echo "::error title=No release cut::release:prepare produced no new tags." exit 1 @@ -524,6 +636,7 @@ jobs: records_file="$RUNNER_TEMP/node-release-records.jsonl" : > "$records_file" for tag in "${new_tags[@]}"; do + tag_target="$(git rev-parse "${tag}^{commit}")" if [[ "$tag" =~ ([0-9]+\.[0-9]+\.[0-9]+([-+.][0-9A-Za-z.-]+)?)$ ]]; then version="${BASH_REMATCH[1]}" else @@ -531,9 +644,10 @@ jobs: fi jq -cn \ --arg tag "$tag" \ + --arg tag_target "$tag_target" \ --arg version "$version" \ --arg bump "$release_bump" \ - '{packages: [], version: $version, tag: $tag, next_dev: "", release_bump: $bump}' \ + '{packages: [], version: $version, tag: $tag, tag_target: $tag_target, next_dev: "", release_bump: $bump}' \ >> "$records_file" done releases="$(jq -cs . "$records_file")" @@ -550,6 +664,7 @@ jobs: { echo "expected_ref=refs/heads/$BASELINE_BRANCH" + echo "source_commit=$RELEASE_SOURCE_COMMIT" echo "primary_version=$primary_version" echo "changelog_pending=$changelog_pending" echo "releases_intermediate<> "$GITHUB_OUTPUT" + + - name: Build and pack delegated distributions + id: delegated-pack + if: vars.RELEASE_LIFECYCLE == 'delegated' + env: + HANDOFF_DIR: ${{ runner.temp }}/node-delegated-release-handoff + RELEASES: ${{ steps.delegated-plan.outputs.releases_intermediate }} + working-directory: ${{ env.WORKING_DIRECTORY }} + shell: bash + run: | + set -euo pipefail + DIST_DIR="$HANDOFF_DIR/tarballs" + RECORDS_FILE="$HANDOFF_DIR/tarballs.jsonl" + rm -rf "$HANDOFF_DIR" + mkdir -p "$DIST_DIR" + : > "$RECORDS_FILE" + + inspect_declared_files() { + local package_dir="$1" + local tarball="$2" + local manifest="$package_dir/package.json" + local declared matched path relative + jq -e ' + .files + | type == "array" and length > 0 and + all(.[]; type == "string" and length > 0 and test("^[^\\r\\n]+$")) + ' "$manifest" > /dev/null + while IFS= read -r declared; do + if [[ "$declared" == /* || "$declared" == "." || "$declared" == ".." || "$declared" == ../* || "$declared" == */../* || "$declared" == */.. || "$declared" == "!"* || "$declared" == *"{"* || "$declared" == *"}"* || "$declared" == *\\* || "$declared" == *"@("* || "$declared" == *"+("* || "$declared" == *"?("* || "$declared" == *"*("* || "$declared" == *"!("* ]]; then + echo "::error title=Unsupported package contents declaration::${manifest} declares an unsafe or unverifiable pattern '$declared'." + exit 1 + fi + matched=0 + if [[ "$declared" == *"*"* || "$declared" == *"?"* || "$declared" == *"["* || "$declared" == *"]"* ]]; then + while IFS= read -r -d '' path; do + relative="${path#"$package_dir"/}" + matched=$((matched + 1)) + if ! tar -tzf "$tarball" | grep -qxF "package/$relative"; then + echo "::error title=Incomplete tarball::$(basename "$tarball") omits declared file '$relative'." + exit 1 + fi + done < <(node - "$package_dir" "$declared" <<'NODE' + const fs = require("fs"); + const path = require("path"); + const root = process.argv[2]; + const pattern = process.argv[3]; + let expression = "^"; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (character === "*") { + let stars = 1; + while (pattern[index + stars] === "*") stars += 1; + if (stars > 1 && pattern[index + stars] === "/") { + expression += "(?:[^/]+/)*"; + index += stars; + } else { + expression += stars > 1 ? ".*" : "[^/]*"; + index += stars - 1; + } + } else if (character === "?") { + expression += "[^/]"; + } else if (character === "[") { + const closing = pattern.indexOf("]", index + 1); + if (closing === -1 || closing === index + 1) process.exit(2); + const characterClass = pattern.slice(index + 1, closing); + if (characterClass.includes("/") || characterClass.includes("\\")) process.exit(2); + expression += `[${characterClass[0] === "!" ? "^" + characterClass.slice(1) : characterClass}]`; + index = closing; + } else if (character === "]") { + process.exit(2); + } else { + expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + } + } + const matcher = new RegExp(`${expression}$`); + const files = []; + const walk = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) walk(candidate); + else if (entry.isFile()) files.push(path.relative(root, candidate).split(path.sep).join("/")); + } + }; + walk(root); + for (const file of files.filter((file) => matcher.test(file)).sort()) process.stdout.write(`${path.join(root, file)}\0`); + NODE + ) + elif [[ -d "$package_dir/$declared" ]]; then + while IFS= read -r -d '' path; do + relative="${path#"$package_dir"/}" + matched=$((matched + 1)) + if ! tar -tzf "$tarball" | grep -qxF "package/$relative"; then + echo "::error title=Incomplete tarball::$(basename "$tarball") omits declared file '$relative'." + exit 1 + fi + done < <(find "$package_dir/$declared" -type f -print0) + elif [[ -f "$package_dir/$declared" ]]; then + matched=1 + if ! tar -tzf "$tarball" | grep -qxF "package/$declared"; then + echo "::error title=Incomplete tarball::$(basename "$tarball") omits declared file '$declared'." + exit 1 + fi + fi + if [[ "$matched" -eq 0 ]]; then + echo "::error title=Missing declared package content::${manifest} declaration '$declared' matches no built files." + exit 1 + fi + done < <(jq -r '.files[]' "$manifest") + } + + release_tag_for() { + local package_name="$1" + local package_version="$2" + local member record release_tag release_version + local -a exact_matches=() + local -a version_matches=() + member="$(printf '%s' "${package_name##*/}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + while IFS= read -r record; do + release_tag="$(jq -r '.tag' <<< "$record")" + release_version="$(jq -r '.version' <<< "$record")" + [[ "$release_version" == "$package_version" ]] || continue + version_matches+=("$release_tag") + if [[ "$release_tag" == "$package_version" || "$release_tag" == "${member}-${package_version}" || "$release_tag" == "${package_name}-${package_version}" ]]; then + exact_matches+=("$release_tag") + fi + done < <(jq -c '.[]' <<< "$RELEASES") + if [[ ${#exact_matches[@]} -eq 1 ]]; then + printf '%s' "${exact_matches[0]}" + elif [[ ${#exact_matches[@]} -eq 0 && ${#version_matches[@]} -eq 1 ]]; then + printf '%s' "${version_matches[0]}" + else + echo "::error title=Ambiguous package release::Package '$package_name@$package_version' does not resolve to exactly one prepared release tag." >&2 + exit 1 + fi + } + + pack_package() { + local package_dir="$1" + local package_name="$2" + local before="$HANDOFF_DIR/tarballs-before.txt" + local after="$HANDOFF_DIR/tarballs-after.txt" + local added="$HANDOFF_DIR/tarballs-added.txt" + local tarball + find "$DIST_DIR" -maxdepth 1 -name '*.tgz' -print | LC_ALL=C sort > "$before" + case "${PACKAGE_MANAGER:-npm}" in + npm) + npm pack "./$package_dir" --pack-destination "$DIST_DIR" + ;; + pnpm) + corepack enable + ( cd "$package_dir" && pnpm pack --pack-destination "$DIST_DIR" ) + ;; + *) + echo "::error title=Invalid package manager::PACKAGE_MANAGER must be npm or pnpm." + exit 1 + ;; + esac + find "$DIST_DIR" -maxdepth 1 -name '*.tgz' -print | LC_ALL=C sort > "$after" + comm -13 "$before" "$after" > "$added" + if [[ "$(wc -l < "$added" | tr -d ' ')" -ne 1 ]]; then + echo "::error title=Invalid package artifact::Packing '$package_name' must produce exactly one new tarball." + exit 1 + fi + tarball="$(<"$added")" + inspect_declared_files "$package_dir" "$tarball" + tarball_package="$(tar -xzOf "$tarball" package/package.json | jq -er '.name | select(type == "string" and length > 0)')" + tarball_version="$(tar -xzOf "$tarball" package/package.json | jq -er '.version | select(type == "string" and length > 0)')" + if [[ "$tarball_package" != "$package_name" ]]; then + echo "::error title=Invalid package artifact::$(basename "$tarball") identifies '$tarball_package', not declared package '$package_name'." + exit 1 + fi + tarball_release_tag="$(release_tag_for "$tarball_package" "$tarball_version")" + jq -cn \ + --arg file "$(basename "$tarball")" \ + --arg package "$package_name" \ + --arg release_tag "$tarball_release_tag" \ + --arg sha256 "$(sha256sum "$tarball" | awk '{print $1}')" \ + --arg version "$tarball_version" \ + '{file: $file, package: $package, release_tag: $release_tag, sha256: $sha256, version: $version}' >> "$RECORDS_FILE" + } + + case "${PACKAGE_MANAGER:-npm}" in + npm) + npm run build + ;; + pnpm) + corepack enable + pnpm run build + ;; + *) + echo "::error title=Invalid package manager::PACKAGE_MANAGER must be npm or pnpm." + exit 1 + ;; + esac + + case "$PROJECT_TYPE" in + single) + package_name="$(jq -er '.name | select(type == "string" and length > 0)' package.json)" + pack_package "." "$package_name" + ;; + workspaces) + jq -e ' + .release.packages + | type == "array" and length > 0 and + all(.[]; type == "string" and length > 0) + ' "$GITHUB_WORKSPACE/.github/inditextech-ci-node.json" > /dev/null + while IFS= read -r package_name; do + member="$(printf '%s' "${package_name##*/}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" + package_dir="packages/$member" + if [[ ! -f "$package_dir/package.json" ]] || [[ "$(jq -r '.name' "$package_dir/package.json")" != "$package_name" ]]; then + echo "::error title=Unknown workspace package::release.packages entry '$package_name' does not resolve to its declared workspace manifest." + exit 1 + fi + pack_package "$package_dir" "$package_name" + done < <(jq -r '.release.packages[]' "$GITHUB_WORKSPACE/.github/inditextech-ci-node.json") + ;; + *) + echo "::error title=Invalid project type::PROJECT_TYPE must be single or workspaces." + exit 1 + ;; + esac + + jq -cs . "$RECORDS_FILE" > "$HANDOFF_DIR/tarballs.json" + echo "handoff_dir=$HANDOFF_DIR" >> "$GITHUB_OUTPUT" + + - name: Create delegated release handoff + id: delegated-handoff + if: vars.RELEASE_LIFECYCLE == 'delegated' + env: + EXPECTED_REF: ${{ steps.delegated-plan.outputs.expected_ref }} + HANDOFF_DIR: ${{ steps.delegated-pack.outputs.handoff_dir }} + RELEASES: ${{ steps.delegated-plan.outputs.releases_intermediate }} + RELEASE_COMMIT: ${{ steps.delegated-source.outputs.release_commit }} + SOURCE_COMMIT: ${{ steps.delegated-plan.outputs.source_commit }} + working-directory: ${{ env.WORKING_DIRECTORY }} + shell: bash + run: | + set -euo pipefail + if ! git merge-base --is-ancestor "$SOURCE_COMMIT" "$RELEASE_COMMIT"; then + echo "::error title=Invalid release provenance::The prepared release is not descended from the verified source commit." + exit 1 + fi + tag_refs=() - while IFS= read -r tag; do + while IFS= read -r record; do + tag="$(jq -r '.tag' <<< "$record")" + tag_target="$(jq -r '.tag_target' <<< "$record")" git check-ref-format --allow-onelevel "refs/tags/$tag" + if [[ "$(git rev-parse "${tag}^{commit}")" != "$tag_target" ]]; then + echo "::error title=Invalid release tag::Prepared tag '$tag' no longer points at its recorded commit." + exit 1 + fi + if ! git merge-base --is-ancestor "$SOURCE_COMMIT" "$tag_target" \ + || ! git merge-base --is-ancestor "$tag_target" "$RELEASE_COMMIT"; then + echo "::error title=Invalid release tag provenance::Prepared tag '$tag' must satisfy source <= tag <= release." + exit 1 + fi tag_refs+=("refs/tags/$tag") - done < <(jq -r '.[].tag' <<< "$RELEASES") - git push --atomic origin "HEAD:$EXPECTED_REF" "${tag_refs[@]}" + done < <(jq -c '.[]' <<< "$RELEASES") + + while IFS= read -r tarball; do + release_tag="$(jq -r '.release_tag' <<< "$tarball")" + package_name="$(jq -r '.package' <<< "$tarball")" + package_version="$(jq -r '.version' <<< "$tarball")" + if [[ "$(jq -r --arg tag "$release_tag" --arg version "$package_version" '[.[] | select(.tag == $tag and .version == $version)] | length' <<< "$RELEASES")" -ne 1 ]]; then + echo "::error title=Invalid package release::Tarball '$package_name@$package_version' is not bound to exactly one prepared release tag." + exit 1 + fi + done < <(jq -c '.[]' "$HANDOFF_DIR/tarballs.json") + + git bundle create "$HANDOFF_DIR/release-source.bundle" "$RELEASE_COMMIT" "${tag_refs[@]}" + source_bundle_sha256="$(sha256sum "$HANDOFF_DIR/release-source.bundle" | awk '{print $1}')" + jq -n \ + --arg expected_ref "$EXPECTED_REF" \ + --arg release_commit "$RELEASE_COMMIT" \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg run_id "$GITHUB_RUN_ID" \ + --arg source_bundle_sha256 "$source_bundle_sha256" \ + --arg source_commit "$SOURCE_COMMIT" \ + --argjson releases "$RELEASES" \ + --slurpfile tarballs "$HANDOFF_DIR/tarballs.json" \ + '{ + schema_version: 1, + repository: $repository, + run_id: $run_id, + run_attempt: $run_attempt, + expected_ref: $expected_ref, + source_commit: $source_commit, + release_commit: $release_commit, + releases: $releases, + tags: [$releases[] | {name: .tag, target: .tag_target}], + source_bundle: {file: "release-source.bundle", sha256: $source_bundle_sha256}, + tarballs: $tarballs[0] + }' > "$HANDOFF_DIR/release-handoff.json" + handoff_digest="$(sha256sum "$HANDOFF_DIR/release-handoff.json" | awk '{print $1}')" + rm -f \ + "$HANDOFF_DIR/tarballs.json" \ + "$HANDOFF_DIR/tarballs.jsonl" \ + "$HANDOFF_DIR/tarballs-added.txt" \ + "$HANDOFF_DIR/tarballs-after.txt" \ + "$HANDOFF_DIR/tarballs-before.txt" + echo "handoff_digest=$handoff_digest" >> "$GITHUB_OUTPUT" + + - name: Upload delegated release handoff + if: vars.RELEASE_LIFECYCLE == 'delegated' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: node-delegated-release-handoff + path: ${{ steps.delegated-pack.outputs.handoff_dir }} + if-no-files-found: error + retention-days: 14 - name: Publish release metadata id: release-metadata env: RELEASES: ${{ steps.release-plan.outputs.releases_intermediate || steps.delegated-plan.outputs.releases_intermediate }} + RELEASE_LIFECYCLE: ${{ vars.RELEASE_LIFECYCLE }} working-directory: ${{ env.WORKING_DIRECTORY }} shell: bash run: | set -euo pipefail + staging_ref="$CI_GOVERNANCE_RELEASE_STAGING_REF" + if [[ "$RELEASE_LIFECYCLE" == "delegated" ]]; then + staging_ref="" + fi { - echo "release_commit=$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD)" + if [[ "$RELEASE_LIFECYCLE" == "delegated" ]]; then + echo "release_commit=${{ steps.delegated-source.outputs.release_commit }}" + else + echo "release_commit=$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD)" + fi + echo "staging_ref=$staging_ref" echo "releases<> "$GITHUB_OUTPUT" - name: Set up asdf-managed Node - uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.0 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 with: tool_versions: ${{ steps.tool-versions.outputs.tool_versions }} asdf_version: ${{ env.ASDF_BRANCH_VERSION }} @@ -760,6 +1182,7 @@ jobs: needs: [prepare-release, build-distributions] if: >- !cancelled() && + vars.RELEASE_LIFECYCLE != 'delegated' && needs.prepare-release.result == 'success' && (needs.build-distributions.result == 'success' || needs.build-distributions.result == 'skipped') runs-on: ubuntu-24.04 @@ -802,12 +1225,59 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Set up asdf-managed Node - uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.0 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 with: tool_versions: ${{ steps.tool-versions.outputs.tool_versions }} asdf_version: ${{ env.ASDF_BRANCH_VERSION }} + - name: Promote the release to the default branch + id: promote + if: vars.RELEASE_LIFECYCLE != 'delegated' && (needs.build-distributions.result == 'success' || needs.build-distributions.result == 'skipped') + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + EXPECTED_REF: ${{ needs.prepare-release.outputs.expected_ref }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RELEASES: ${{ needs.prepare-release.outputs.releases }} + RELEASE_COMMIT: ${{ needs.prepare-release.outputs.release_commit }} + STAGING_REF: ${{ needs.prepare-release.outputs.staging_ref }} + working-directory: ${{ env.WORKING_DIRECTORY }} + shell: bash + run: | + set -euo pipefail + gh auth setup-git + git config user.name "${APP_SLUG}[bot]" + git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" + + git fetch --no-tags origin "+${STAGING_REF}:refs/ci-governance/staged" 2>/dev/null || true + if [[ "$(git ls-remote origin "$EXPECTED_REF" | cut -f1)" == "$RELEASE_COMMIT" ]]; then + echo "::notice title=Promotion skipped::$EXPECTED_REF already points at the release commit." + git checkout --force --detach "$RELEASE_COMMIT" + exit 0 + fi + + if [[ "$(git rev-parse --verify --quiet refs/ci-governance/staged || true)" != "$RELEASE_COMMIT" ]]; then + echo "::error title=Release staging mismatch::$STAGING_REF does not point at the release commit." + exit 1 + fi + git checkout --force --detach "$RELEASE_COMMIT" + + tag_refs=() + while IFS= read -r record; do + tag="$(jq -r '.tag' <<< "$record")" + version="$(jq -r '.version' <<< "$record")" + git check-ref-format --allow-onelevel "refs/tags/$tag" + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "::error title=Tag conflict::$tag already exists." + exit 1 + fi + git tag -a "$tag" -m "Release $version" + tag_refs+=("refs/tags/$tag") + done < <(jq -c '.[]' <<< "$RELEASES") + + git push --atomic origin "${RELEASE_COMMIT}:${EXPECTED_REF}" "${tag_refs[@]}" + - name: Next development iteration and sync PR + if: steps.promote.outcome == 'success' env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} BASELINE_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }} @@ -815,7 +1285,6 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} RELEASES: ${{ needs.prepare-release.outputs.releases }} RELEASE_COMMIT: ${{ needs.prepare-release.outputs.release_commit }} - RELEASE_LIFECYCLE: ${{ vars.RELEASE_LIFECYCLE }} working-directory: ${{ env.WORKING_DIRECTORY }} shell: bash run: | @@ -833,18 +1302,7 @@ jobs: if [[ "$(git rev-parse HEAD)" != "$RELEASE_COMMIT" ]]; then echo "::notice title=Next-dev skipped::Branch already advanced past the release commit." else - if [[ "$RELEASE_LIFECYCLE" == "delegated" ]]; then - release_bump="$(jq -r '.[0].release_bump // ""' <<< "$RELEASES")" - case "${PACKAGE_MANAGER:-npm}" in - npm) RELEASE_BUMP="$release_bump" npm run version:development ;; - pnpm) corepack enable; RELEASE_BUMP="$release_bump" pnpm run version:development ;; - *) echo "::error title=Invalid package manager::PACKAGE_MANAGER must be npm or pnpm."; exit 1 ;; - esac - git -C "$GITHUB_WORKSPACE" add --update -- "$WORKING_DIRECTORY" - if ! git diff --cached --quiet; then - git commit -m "[node-release] prepare for next development iteration" - fi - else + { if [[ "$PROJECT_TYPE" == "single" ]]; then next_dev="$(jq -r '.[0].next_dev' <<< "$RELEASES")" npm pkg set "version=$next_dev" @@ -862,7 +1320,6 @@ jobs: done < <(jq -c '.[]' <<< "$RELEASES") fi - # Same cross-dependency reconciliation as the release strip, on the next-dev bump: point each sibling spec at the bumped member's next_dev and regenerate the lockfile, or main keeps cross-deps at the released version with a stale lockfile and `npm ci` fails (needs bash>=4). if [[ "$PROJECT_TYPE" == "workspaces" ]]; then declare -A member_version=() while IFS= read -r record; do @@ -894,7 +1351,7 @@ jobs: git -C "$GITHUB_WORKSPACE" add --update -- "$WORKING_DIRECTORY" git commit -m "[node-release] prepare for next development iteration" - fi + } git push origin "HEAD:refs/heads/$BASELINE_BRANCH" fi @@ -919,3 +1376,19 @@ jobs: --title "Sync release $version to $develop_branch" \ --body "**Automated pull request** syncing the release cut on \`$BASELINE_BRANCH\` back into \`$develop_branch\`." fi + + - name: Discard the release staging ref + if: always() + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + STAGING_REF: ${{ needs.prepare-release.outputs.staging_ref }} + working-directory: ${{ env.WORKING_DIRECTORY }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${STAGING_REF:-}" ]]; then + echo "::notice title=Nothing staged::No staging ref was written, so there is nothing to discard." + exit 0 + fi + gh auth setup-git + git push origin ":${STAGING_REF}" diff --git a/.github/workflows/code-npm_node-sonarcloud-analysis.yml b/.github/workflows/code-npm_node-sonarcloud-analysis.yml index f4eda71..8325255 100644 --- a/.github/workflows/code-npm_node-sonarcloud-analysis.yml +++ b/.github/workflows/code-npm_node-sonarcloud-analysis.yml @@ -63,7 +63,6 @@ jobs: shell: bash run: | set -euo pipefail - # Reject any .tool-versions line that is not a strict " " before asdf reads it (npm is pinned via package.json packageManager, not here). if grep -Evq '^[a-zA-Z0-9_-]+ [a-zA-Z0-9._+-]+$' .tool-versions; then echo "::error title=Invalid tool-versions::${WORKING_DIRECTORY}/.tool-versions is malformed." exit 1 @@ -76,7 +75,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Set up asdf-managed Node - uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.0 + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 with: tool_versions: ${{ steps.tool-versions.outputs.tool_versions }} asdf_version: ${{ env.ASDF_BRANCH_VERSION }} @@ -86,7 +85,6 @@ jobs: shell: bash run: | set -euo pipefail - # `test` is the governed lifecycle script and must emit the lcov report the scanner reads; build runs first so the scanner sees buildable sources. case "$PROJECT_TYPE" in single|workspaces) npm ci @@ -103,7 +101,6 @@ jobs: shell: bash run: | set -euo pipefail - # Resolve sonar.sources and lcov coverage paths from the repository root; a workspaces build contributes every member so none is silently excluded. case "$PROJECT_TYPE" in single) sonar_sources="${WORKING_DIRECTORY}" @@ -114,12 +111,10 @@ jobs: coverage=() for member_dir in "${WORKING_DIRECTORY}"/packages/*/; do member_dir="${member_dir%/}" - # Only members with a package.json are analyzable units; skip stray directories under packages/. if [[ ! -f "${member_dir}/package.json" ]]; then continue fi sources+=("${member_dir}") - # Attribute each member's coverage from its own lcov report; the scanner ignores a path a member without tests never wrote. coverage+=("${member_dir}/coverage/lcov.info") done if [[ "${#sources[@]}" -eq 0 ]]; then @@ -134,6 +129,12 @@ jobs: exit 1 ;; esac + for governed_value in "$sonar_sources" "$sonar_coverage"; do + if [[ "$governed_value" == *$'\n'* ]]; then + echo "::error title=Invalid Sonar scope::Resolved analysis paths must not contain newlines." >&2 + exit 1 + fi + done { echo "SONAR_SOURCES=${sonar_sources}" echo "SONAR_COVERAGE_PATHS=${sonar_coverage}" diff --git a/.github/workflows/code-release_preview.yml b/.github/workflows/code-release_preview.yml index a1d9a97..30de715 100644 --- a/.github/workflows/code-release_preview.yml +++ b/.github/workflows/code-release_preview.yml @@ -33,7 +33,7 @@ jobs: timeout-minutes: 15 permissions: contents: read - pull-requests: write # Publishes the calculated release preview. + pull-requests: write steps: - name: Check out pull request head uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -43,10 +43,11 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Check CHANGELOG has Unreleased changes - # Skip for PRs that opt out of a release (skip-release label) and for the engine's automated reconcile PR, which never carries release content. if: >- !contains(github.event.pull_request.labels.*.name, 'skip-release') && - github.event.pull_request.head.ref != 'automated/ci-governance-sync' + github.event.pull_request.head.ref != 'automated/ci-governance-sync' && + github.event.pull_request.user.login != 'dependabot[bot]' && + github.event.pull_request.user.login != 'renovate[bot]' env: GH_TOKEN: ${{ github.token }} GITHUB_EVENT_NUMBER: ${{ github.event.pull_request.number }} @@ -104,18 +105,16 @@ jobs: merge_strategy="Squash and merge" fi - # npm version helpers (mirror release-core): release version is strip-SNAPSHOT of each unit's package.json; the release-type label only drives the next-dev bump. - member_dir() { # -> flat workspaces member directory - # @scope/api -> api, client_lib -> client-lib (mirrors the CREATE scaffolder). + member_dir() { printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' } strip_snapshot() { printf '%s' "${1%-SNAPSHOT}"; } - read_version() { # + read_version() { local manifest="package.json" [[ -n "$1" ]] && manifest="$1/package.json" jq -r '.version' "$manifest" } - bump_semver() { # + bump_semver() { local major minor patch IFS=. read -r major minor patch <<< "$1" case "$2" in diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d855c42..f763268 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,7 +21,6 @@ concurrency: cancel-in-progress: true jobs: - # Resolves code-scanning availability at runtime so the analysis skips cleanly (green) where no Advanced Security seat is licensed, and starts automatically once a repository becomes public or gains a seat. scanning-availability: name: Resolve code scanning availability if: >- @@ -44,7 +43,7 @@ jobs: ) runs-on: ubuntu-24.04 permissions: - contents: read # Reads repository metadata to resolve availability. + contents: read outputs: enabled: ${{ steps.resolve.outputs.enabled }} steps: @@ -52,21 +51,19 @@ jobs: id: resolve env: GH_TOKEN: ${{ github.token }} - # Manual opt-in (CODE_SCANNING_ENABLED='true') for a seated private/internal repository whose seat status the workflow token cannot read. FORCE_ENABLED: ${{ vars.CODE_SCANNING_ENABLED }} run: | set -euo pipefail - # Visibility is always readable; the security-and-analysis block is admin-token-only, so treat a missing value as unknown. visibility="$(gh api "/repos/${GITHUB_REPOSITORY}" --jq '.visibility' 2>/dev/null || echo unknown)" seat="$(gh api "/repos/${GITHUB_REPOSITORY}" \ --jq '.security_and_analysis.advanced_security.status // "unknown"' 2>/dev/null || echo unknown)" enabled=false if [ "${visibility}" = "public" ]; then - enabled=true # Code scanning is always free and available on public repositories. + enabled=true elif [ "${seat}" = "enabled" ]; then - enabled=true # A Code Security seat is attached, so analysis is licensed. + enabled=true elif [ "${FORCE_ENABLED:-}" = "true" ]; then - enabled=true # Operator opted this repository in explicitly. + enabled=true fi echo "Code scanning availability: visibility=${visibility} seat=${seat} enabled=${enabled}" echo "enabled=${enabled}" >> "${GITHUB_OUTPUT}" @@ -77,17 +74,16 @@ jobs: if: needs.scanning-availability.outputs.enabled == 'true' runs-on: ubuntu-24.04 permissions: - actions: read # Lets CodeQL inspect workflow metadata. + actions: read contents: read - packages: read # Lets CodeQL resolve package metadata during analysis. - security-events: write # Uploads CodeQL results to Code Scanning. + packages: read + security-events: write strategy: fail-fast: false matrix: include: - language: actions build-mode: none - # JS/TS is analyzed buildless: CodeQL's extractor reads source directly, so no Node/npm setup or workspace build is required. - language: javascript-typescript build-mode: none steps: @@ -97,21 +93,17 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4 with: build-mode: ${{ matrix.build-mode }} languages: ${{ matrix.language }} - # These three `actions` queries are false positives on the governed lanes: CodeQL propagates a caller's issue_comment/workflow_dispatch triggers through `workflow_call` and cannot evaluate the job-level `if:` guards, yet every flagged site is mitigated in-template (40-hex merge-commit SHA validation, whitelisted bump labels, no PR-head code in privileged jobs); scoped to these rule IDs only. + # Release caches are branch-scoped, so a release-core build can never write the default-branch cache. config: | query-filters: - - exclude: - id: actions/untrusted-checkout/critical - - exclude: - id: actions/envvar-injection/critical - exclude: id: actions/cache-poisoning/poisonable-step - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4 with: category: /language:${{ matrix.language }} diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index 66c196d..8d73d82 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -19,7 +19,6 @@ jobs: timeout-minutes: 10 permissions: contents: read - checks: write # Lets reviewdog publish actionlint check results. steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -31,7 +30,7 @@ jobs: - name: Lint GitHub Actions workflows uses: reviewdog/action-actionlint@dbe5299849118fd6f099ba563d263d770955a64a # v1.73.2 with: - reporter: github-pr-check + reporter: github-annotations fail_level: error repo-linter: diff --git a/.github/workflows/push-verify.yml b/.github/workflows/push-verify.yml index 6f0c7b0..fc23062 100644 --- a/.github/workflows/push-verify.yml +++ b/.github/workflows/push-verify.yml @@ -22,7 +22,6 @@ jobs: timeout-minutes: 10 permissions: contents: read - checks: write # Lets reviewdog publish actionlint check results. steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -32,56 +31,9 @@ jobs: - name: Lint GitHub Actions workflows uses: reviewdog/action-actionlint@dbe5299849118fd6f099ba563d263d770955a64a # v1.73.2 with: - reporter: github-check + reporter: github-annotations fail_level: error - scorecard-analysis: - if: >- - github.ref_type == 'branch' && - github.event.repository.visibility == 'public' && - github.ref_name == github.event.repository.default_branch - name: Scorecard analysis - runs-on: ubuntu-24.04 - timeout-minutes: 20 - permissions: - contents: read - id-token: write # Lets Scorecard prove the published result's provenance. - security-events: write # Uploads the generated Scorecard SARIF to Code Scanning. - steps: - - name: "Checkout code" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - - name: "Run analysis" - uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 - with: - results_file: results.sarif - results_format: sarif - # Scorecard team runs a weekly scan of public GitHub repos, - # see https://github.com/ossf/scorecard#public-data. - # Setting `publish_results: true` helps us scale by leveraging your workflow to - # extract the results instead of relying on our own infrastructure to run scans. - # And it's free for you! - publish_results: true - - # Upload the results as artifacts (optional). Commenting out will disable - # uploads of run results in SARIF format to the repository Actions tab. - # https://docs.github.com/en/actions/advanced-guides/storing-workflow-data-as-artifacts - - name: "Upload artifact" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - - name: Upload SARIF to Code Scanning - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 - with: - sarif_file: results.sarif - category: scorecard - repo-linter: if: >- github.ref_type == 'branch' && diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml new file mode 100644 index 0000000..36f5f92 --- /dev/null +++ b/.github/workflows/scorecard-analysis.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: 2026 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +# SPDX-License-Identifier: Apache-2.0 + +name: Scorecard analysis + +permissions: + contents: read + +on: + push: + +concurrency: + group: scorecard-analysis-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scorecard-analysis: + if: >- + github.ref_type == 'branch' && + github.ref_name == github.event.repository.default_branch + name: Scorecard analysis + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + issues: read # Lets Scorecard read issue metadata via the GraphQL API. + pull-requests: read # Lets Scorecard read pull-request metadata via the GraphQL API. + id-token: write # Lets Scorecard prove the published result's provenance. + security-events: write # Uploads the generated Scorecard SARIF to Code Scanning. + actions: read # Lets the SARIF upload read workflow run metadata on non-public repositories. + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + with: + results_file: results.sarif + results_format: sarif + publish_results: ${{ github.event.repository.visibility == 'public' }} + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Resolve code scanning availability + id: scanning-availability + env: + GH_TOKEN: ${{ github.token }} + FORCE_ENABLED: ${{ vars.CODE_SCANNING_ENABLED }} + run: | + set -euo pipefail + visibility="$(gh api "/repos/${GITHUB_REPOSITORY}" --jq '.visibility' 2>/dev/null || echo unknown)" + seat="$(gh api "/repos/${GITHUB_REPOSITORY}" --jq '.security_and_analysis.advanced_security.status // "unknown"' 2>/dev/null || echo unknown)" + enabled=false + if [ "${visibility}" = "public" ] || [ "${seat}" = "enabled" ] || [ "${FORCE_ENABLED:-}" = "true" ]; then + enabled=true + fi + echo "Code scanning availability: visibility=${visibility} seat=${seat} enabled=${enabled}" + echo "enabled=${enabled}" >> "${GITHUB_OUTPUT}" + + - name: Upload SARIF to Code Scanning + if: steps.scanning-availability.outputs.enabled == 'true' + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + with: + sarif_file: results.sarif + category: scorecard + + - name: Report unavailable Code Scanning + if: steps.scanning-availability.outputs.enabled != 'true' + run: echo "::notice::Scorecard SARIF is retained as an artifact; Code Scanning ingestion is unavailable." diff --git a/.github/workflows/sync-to-develop.yml b/.github/workflows/sync-to-develop.yml index a9ea870..9c06104 100644 --- a/.github/workflows/sync-to-develop.yml +++ b/.github/workflows/sync-to-develop.yml @@ -17,18 +17,67 @@ jobs: if: >- github.event.pull_request.merged && vars.DEVELOPMENT_FLOW == 'git-flow' && - ( - github.event.pull_request.base.ref == github.event.repository.default_branch || - startsWith(github.event.pull_request.base.ref, format('{0}-', github.event.repository.default_branch)) - ) && !contains(join(github.event.pull_request.labels.*.name, ','), 'release-type') runs-on: ubuntu-24.04 timeout-minutes: 15 concurrency: group: sync-to-develop-${{ github.repository }}-${{ github.event.pull_request.base.ref }} cancel-in-progress: false + env: + SYNC_BASE_REF: ${{ github.event.pull_request.base.ref }} steps: + - name: Resolve sync context + id: context + env: + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + DEVELOPMENT_BRANCH: ${{ vars.GIT_FLOW_DEVELOPMENT_BRANCH }} + RELEASE_BRANCH: ${{ vars.GIT_FLOW_RELEASE_BRANCH }} + run: | + set -euo pipefail + + if [[ -z "$RELEASE_BRANCH" || -z "$DEVELOPMENT_BRANCH" ]]; then + echo "::error title=Missing git-flow branch roles::GIT_FLOW_RELEASE_BRANCH and GIT_FLOW_DEVELOPMENT_BRANCH are required." + exit 1 + fi + + if [[ "$BASE_BRANCH" == "$RELEASE_BRANCH" ]]; then + develop_branch="$DEVELOPMENT_BRANCH" + elif [[ "$BASE_BRANCH" == "$RELEASE_BRANCH"-* ]]; then + suffix="${BASE_BRANCH#"$RELEASE_BRANCH"-}" + if [[ -z "$suffix" ]]; then + echo "::notice title=Sync skipped::$BASE_BRANCH has no release suffix." + echo "should_sync=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + develop_branch="$DEVELOPMENT_BRANCH-$suffix" + else + echo "::notice title=Sync skipped::$BASE_BRANCH is not a configured release branch." + echo "should_sync=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + { + echo "develop_branch=$develop_branch" + echo "should_sync=true" + } >> "$GITHUB_OUTPUT" + + # The checkout runs *before* any App credential exists. This event carries + # the base repository's secrets, so ordering is the cheap half of the + # defence: content that lands in the workspace cannot reach a credential + # that has not been minted yet. + - name: Checkout the exact merged revision + if: steps.context.outputs.should_sync == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + # Deliberately the base branch, not the merge SHA. Central policy + # requires this ref, and the branch is trusted here: this event only + # fires for an already-merged pull request. + ref: ${{ env.SYNC_BASE_REF }} + - name: Create GitHub App token + if: steps.context.outputs.should_sync == 'true' id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -37,47 +86,24 @@ jobs: permission-contents: write permission-issues: write permission-pull-requests: write + # Required because the synchronization branch carries workflow files + # from the default branch into develop, and Git refuses that push + # without it. permission-workflows: write - - name: Checkout base branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - ref: ${{ github.event.pull_request.base.ref }} - token: ${{ steps.app-token.outputs.token }} - - name: Prepare sync branch id: prepare + if: steps.context.outputs.should_sync == 'true' env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} BASE_BRANCH: ${{ github.event.pull_request.base.ref }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DEVELOP_BRANCH: ${{ steps.context.outputs.develop_branch }} GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail - if [[ "$BASE_BRANCH" == "$DEFAULT_BRANCH" ]]; then - develop_branch=develop - elif [[ "$BASE_BRANCH" == "$DEFAULT_BRANCH"-* ]]; then - suffix="${BASE_BRANCH#"$DEFAULT_BRANCH"-}" - if [[ -z "$suffix" ]]; then - echo "::notice title=Sync skipped::$BASE_BRANCH has no release suffix." - echo "should_sync=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - develop_branch="develop-$suffix" - else - echo "::notice title=Sync skipped::$BASE_BRANCH is not a default-branch release role." - echo "should_sync=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "$BASE_BRANCH" == "$develop_branch" ]]; then - echo "::notice title=Sync skipped::The source branch is already the development branch." - echo "should_sync=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + # shellcheck disable=SC2153 + develop_branch="$DEVELOP_BRANCH" sync_branch="automated/sync-from-${BASE_BRANCH}-to-${develop_branch}" @@ -123,7 +149,7 @@ jobs: if: steps.prepare.outputs.should_sync == 'true' env: BASE_BRANCH: ${{ github.event.pull_request.base.ref }} - DEVELOP_BRANCH: ${{ steps.prepare.outputs.develop_branch }} + DEVELOP_BRANCH: ${{ steps.context.outputs.develop_branch }} GH_TOKEN: ${{ steps.app-token.outputs.token }} SOURCE_PULL_REQUEST: ${{ github.event.pull_request.number }} SYNC_BRANCH: ${{ steps.prepare.outputs.sync_branch }}