diff --git a/.github/actions/create-release-bundle/action.yaml b/.github/actions/create-release-bundle/action.yaml index 86beeeeb..388e45d6 100644 --- a/.github/actions/create-release-bundle/action.yaml +++ b/.github/actions/create-release-bundle/action.yaml @@ -19,6 +19,13 @@ inputs: description: Whether to run in dry-run mode required: false default: "false" + bundle-revision: + description: | + Appended to `version` to form the bundle version, so a rebuild of a release becomes a new + bundle revision rather than colliding with the bundle the first build created. The artifacts + keep the clean release version. Set to `auto` to use the run id and attempt. + required: false + default: "" entrypoint-path: description: Path to the create-release-bundle entrypoint script (relative to workspace) required: false @@ -30,10 +37,15 @@ inputs: `jf release-bundle-annotate` after `jf release-bundle-create`. Relative paths are resolved from the job workspace. required: false default: "" +outputs: + bundle-version: + description: Bundle version that was created, including the revision when one was requested + value: ${{ steps.create.outputs.bundle-version }} runs: using: composite steps: - name: Create Release Bundle + id: create shell: bash run: | entrypoint="${{ inputs.entrypoint-path }}" @@ -51,10 +63,19 @@ runs: if [[ -n "${{ inputs.bundle-metadata-path }}" ]]; then meta_arg=(--bundle-metadata "${{ inputs.bundle-metadata-path }}") fi + revision_arg=() + revision="${{ inputs.bundle-revision }}" + if [[ "$revision" == "auto" ]]; then + revision="${{ github.run_id }}-${{ github.run_attempt }}" + fi + if [[ -n "$revision" ]]; then + revision_arg=(--revision "$revision") + fi "$entrypoint" \ --project "${{ inputs.jf-project }}" \ --build-names "${{ inputs.build-names }}" \ --bundle-name "${{ inputs.bundle-name }}" \ --version "${{ inputs.version }}" \ "${meta_arg[@]}" \ + "${revision_arg[@]}" \ $dry_run_arg diff --git a/.github/actions/delete-release-bundle/guard-promoted-beyond.sh b/.github/actions/delete-release-bundle/guard-promoted-beyond.sh index 7119da6e..7ece0ad1 100644 --- a/.github/actions/delete-release-bundle/guard-promoted-beyond.sh +++ b/.github/actions/delete-release-bundle/guard-promoted-beyond.sh @@ -3,66 +3,32 @@ # Matches tf-artifactory cleanup_exclude_downstream_of_dev (TEST, STAGE, PREVIEW, INTERNAL, PROD). set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../workflows/lib/jfrog-lifecycle.sh" + bundle_name="${1:?bundle name required}" bundle_version="${2:?bundle version required}" jf_project="${3:?jf project required}" guard_label="${4:-promotion guard}" -records_path="/lifecycle/api/v2/promotion/records/${bundle_name}?project=${jf_project}&filter_by=${bundle_version}&order_by=created_millis&order_asc=true" echo "${guard_label}: checking promotion records for ${bundle_name}/${bundle_version}..." -records_json='{"promotions":[]}' -set +e -fetched_records=$(jf rt curl -X GET "$records_path" -H "Accept: application/json" 2>jfrog-error.log) -status=$? -set -e - -if [ "$status" -ne 0 ]; then - error_text="$( 0' >/dev/null; then - if echo "$records_json" | jq -e '.errors[]? | (.status | tostring) == "404"' >/dev/null; then - echo "::warning::Promotion records endpoint returned 404; treating this as no existing promotion records." - exit 0 - fi - echo "::error::Failed to fetch promotion records for ${bundle_name}/${bundle_version}." +if ! records=$(jfrog_promotion_records "$bundle_name" "$jf_project"); then + echo "::error::${guard_label}: could not verify promotions for ${bundle_name}/${bundle_version}. Refusing to delete." exit 1 fi -higher_envs=$(echo "$records_json" | jq -r --arg version "$bundle_version" ' - [(.promotions // [])[]? - | select((.release_bundle_version // .releaseBundleVersion // .version // "") == $version) - | select((.status // "COMPLETED") == "COMPLETED") - | (.environment // .target_environment // .targetEnvironment // empty) - | select(. == "TEST" or . == "STAGE" or . == "PREVIEW" or . == "INTERNAL" or . == "PROD")] - | unique - | join(", ") -') -if [ -n "$higher_envs" ]; then - echo "::error::Release bundle ${bundle_name}/${bundle_version} already promoted beyond DEV (${higher_envs}). Refusing to delete." +stages=$(jfrog_promotion_stages "$records" "$bundle_version") +beyond_dev=$(echo "$stages" | grep -Fx -e TEST -e STAGE -e PREVIEW -e INTERNAL -e PROD | paste -sd, - || true) + +if [[ -n $beyond_dev ]]; then + echo "::error::Release bundle ${bundle_name}/${bundle_version} is promoted beyond DEV (${beyond_dev}). Refusing to delete. Remove the promotion first if this is intended." exit 1 fi -existing_envs=$(echo "$records_json" | jq -r --arg version "$bundle_version" ' - [(.promotions // [])[]? - | select((.release_bundle_version // .releaseBundleVersion // .version // "") == $version) - | select((.status // "COMPLETED") == "COMPLETED") - | (.environment // .target_environment // .targetEnvironment // empty)] - | unique - | join(", ") -') -if [ -n "$existing_envs" ]; then - echo "Existing release bundle promotions (${guard_label}): ${existing_envs}." +if [[ -n $stages ]]; then + echo "${guard_label}: existing promotions: $(echo "$stages" | paste -sd, -)." else - echo "No existing release bundle promotions found (${guard_label})." + echo "${guard_label}: no existing promotions." fi diff --git a/.github/actions/delete-release-bundle/tests/bats/test_guard_promoted_beyond.bats b/.github/actions/delete-release-bundle/tests/bats/test_guard_promoted_beyond.bats new file mode 100644 index 00000000..09bbab82 --- /dev/null +++ b/.github/actions/delete-release-bundle/tests/bats/test_guard_promoted_beyond.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats + +setup() { + GUARD="${BATS_TEST_DIRNAME}/../../guard-promoted-beyond.sh" + export JF_RECORDS="${BATS_TEST_TMPDIR}/records.json" + + mkdir -p "${BATS_TEST_TMPDIR}/bin" + cat >"${BATS_TEST_TMPDIR}/bin/jf" <<'STUB' +#!/usr/bin/env bash +if [[ $1 == "config" && $2 == "export" ]]; then + printf '%s' '{"url":"https://jfrog.test/","accessToken":"tkn"}' | base64 -w0 + exit 0 +fi +exit 0 +STUB + cat >"${BATS_TEST_TMPDIR}/bin/curl" <<'STUB' +#!/usr/bin/env bash +cat >/dev/null +if [[ -n ${CURL_FAILS-} ]]; then + echo "curl: (22) HTTP 503" >&2 + exit 22 +fi +cat "$JF_RECORDS" +exit 0 +STUB + chmod +x "${BATS_TEST_TMPDIR}/bin/jf" "${BATS_TEST_TMPDIR}/bin/curl" + export PATH="${BATS_TEST_TMPDIR}/bin:$PATH" + cd "$BATS_TEST_TMPDIR" || exit 1 +} + +# Each entry is "version:stage". +records() { + local entries=("$@") json="[]" e version stage + for e in "${entries[@]}"; do + version="${e%%:*}" + stage="${e##*:}" + json=$(echo "$json" | jq --arg v "$version" --arg s "$stage" \ + '. + [{release_bundle_version: $v, environment: $s, status: "COMPLETED"}]') + done + echo "{\"promotions\": $json}" >"$JF_RECORDS" +} + +guard() { + run bash "$GUARD" my-bundle "$1" test "test guard" +} + +@test "refuses a version promoted beyond DEV" { + records "1.0.0:DEV" "1.0.0:TEST" + guard 1.0.0 + [ "$status" -eq 1 ] + [[ $output == *"promoted beyond DEV"* ]] + [[ $output == *"TEST"* ]] +} + +@test "refuses at every stage past DEV" { + for stage in TEST STAGE PREVIEW INTERNAL PROD; do + records "1.0.0:$stage" + guard 1.0.0 + [ "$status" -eq 1 ] + [[ $output == *"promoted beyond DEV"* ]] + done +} + +@test "allows a version promoted only to DEV" { + records "1.0.0:DEV" + guard 1.0.0 + [ "$status" -eq 0 ] + [[ $output == *"existing promotions: DEV"* ]] +} + +@test "allows a version with no promotions" { + records + guard 1.0.0 + [ "$status" -eq 0 ] + [[ $output == *"no existing promotions"* ]] +} + +@test "ignores promotions belonging to another version" { + records "9.9.9:PROD" "1.0.0:DEV" + guard 1.0.0 + [ "$status" -eq 0 ] +} + +@test "refuses when the records cannot be read" { + records "1.0.0:DEV" + CURL_FAILS=1 guard 1.0.0 + [ "$status" -eq 1 ] + [[ $output == *"could not verify promotions"* ]] +} diff --git a/.github/workflows/create-release-bundle/README.md b/.github/workflows/create-release-bundle/README.md index 0d52d861..ae24c312 100644 --- a/.github/workflows/create-release-bundle/README.md +++ b/.github/workflows/create-release-bundle/README.md @@ -8,21 +8,22 @@ This workflow creates JFrog release bundles by bundling one or more builds into ## Inputs -| Input | Description | Required | Default | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------- | -| `jf-project` | JFrog Artifactory project name | Yes | - | -| `jf-build-names` | Comma-separated list of `build-name:version` pairs to include (e.g. `"app-build:1.2.3,client-build:2.1.0"`) | Yes | - | -| `jf-bundle-name` | Name for the release bundle | Yes | - | -| `version` | Version of the release bundle | Yes | - | -| `jf-url` | JFrog Artifactory URL | No | `https://artifact.aerospike.io` | -| `oidc-provider-name` | OIDC provider name for authentication | No | `gh-aerospike` | -| `oidc-audience` | OIDC audience for authentication | No | `aerospike` | -| `runs-on` | The runner to use for the build | No | `ubuntu-22.04` | -| `gh-checkout-path` | Directory to checkout the shared-workflows repository into | No | `shared-workflows` | -| `gh-workflows-ref` | Git ref for shared-workflows (**should match `uses:`**) | Yes | - | -| `dry-run` | Whether to run in dry-run mode | No | `false` | -| `bundle-metadata-path` | Optional path to `.maven-bundle-metadata.json` (e.g. detect-artifacts `bundle-metadata-path`). Applied as bundle properties after create. | No | _(empty)_ | -| `gh-bundle-metadata-artifact-name` | When set, downloads this GitHub artifact and uses the contained `.maven-bundle-metadata.json` (e.g. `reusable_deploy-artifacts` output `bundle-metadata-artifact-name`). Overrides `bundle-metadata-path` when both are set. | No | _(empty)_ | +| Input | Description | Required | Default | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------- | +| `jf-project` | JFrog Artifactory project name | Yes | - | +| `jf-build-names` | Comma-separated list of `build-name:version` pairs to include (e.g. `"app-build:1.2.3,client-build:2.1.0"`) | Yes | - | +| `jf-bundle-name` | Name for the release bundle | Yes | - | +| `version` | Version of the release bundle | Yes | - | +| `bundle-revision` | Appended to `version` to form the bundle version, so a rebuild of a release becomes a new bundle revision instead of colliding with the existing bundle. Artifacts keep the clean release version. Use `auto` for the run id and attempt. | No | `""` | +| `jf-url` | JFrog Artifactory URL | No | `https://artifact.aerospike.io` | +| `oidc-provider-name` | OIDC provider name for authentication | No | `gh-aerospike` | +| `oidc-audience` | OIDC audience for authentication | No | `aerospike` | +| `runs-on` | The runner to use for the build | No | `ubuntu-22.04` | +| `gh-checkout-path` | Directory to checkout the shared-workflows repository into | No | `shared-workflows` | +| `gh-workflows-ref` | Git ref for shared-workflows (**should match `uses:`**) | Yes | - | +| `dry-run` | Whether to run in dry-run mode | No | `false` | +| `bundle-metadata-path` | Optional path to `.maven-bundle-metadata.json` (e.g. detect-artifacts `bundle-metadata-path`). Applied as bundle properties after create. | No | _(empty)_ | +| `gh-bundle-metadata-artifact-name` | When set, downloads this GitHub artifact and uses the contained `.maven-bundle-metadata.json` (e.g. `reusable_deploy-artifacts` output `bundle-metadata-artifact-name`). Overrides `bundle-metadata-path` when both are set. | No | _(empty)_ | ## Example Usage @@ -76,3 +77,22 @@ Run the basic test suite: ```bash .github/workflows/create-release-bundle/test-entrypoint.sh ``` + +## Bundle revisions + +A release version alone is not unique per build, so rebuilding a release collides with the +bundle its first build created. Set `bundle-revision` to give each build its own bundle +version: + +```yaml +with: + version: 1.2.3 + bundle-revision: auto # bundle becomes 1.2.3-- +``` + +The artifacts inside keep the clean release version. Only the bundle version carries the +revision, which is what lets two builds of one release exist at once so a failed build can +be superseded. See [Promote Release Bundle](../promote-release-bundle/README.md). + +The resolved bundle version is available as the `bundle-version` output, and is the value to +pass to the promotion workflow. diff --git a/.github/workflows/create-release-bundle/entrypoint.sh b/.github/workflows/create-release-bundle/entrypoint.sh index ad74c7b7..80c61c6d 100755 --- a/.github/workflows/create-release-bundle/entrypoint.sh +++ b/.github/workflows/create-release-bundle/entrypoint.sh @@ -25,6 +25,7 @@ error() { # Default values DRY_RUN="false" BUNDLE_METADATA_PATH="" +REVISION="" show_help() { echo "Usage: $0 --project --build-names --bundle-name --version [OPTIONS]" >&2 @@ -42,6 +43,10 @@ show_help() { echo " --bundle-metadata Optional JSON (e.g. .maven-bundle-metadata.json from detect-artifacts)." >&2 echo " When the file exists, key=value pairs are applied to the bundle via" >&2 echo " jf release-bundle-annotate after create." >&2 + echo " --revision Appended to the version to form the bundle version, so a" >&2 + echo " rebuild of a release becomes a new bundle revision instead of" >&2 + echo " colliding with the existing one. Leave unset for a bundle" >&2 + echo " version equal to the release version." >&2 echo " --help, -h Show this help message" >&2 echo "" >&2 echo "Examples:" >&2 @@ -69,6 +74,10 @@ while [[ $# -gt 0 ]]; do VERSION="$2" shift 2 ;; + --revision) + REVISION="$2" + shift 2 + ;; --dry-run) DRY_RUN="true" shift @@ -170,6 +179,16 @@ main() { echo "Build names: $BUILD_NAMES" >&2 echo "Bundle name: $BUNDLE_NAME" >&2 echo "Version: $VERSION" >&2 + + # Only the bundle version takes the revision. Artifacts keep $VERSION. + BUNDLE_VERSION="$VERSION" + if [[ -n $REVISION ]]; then + BUNDLE_VERSION="${VERSION}-${REVISION}" + echo "Bundle version: $BUNDLE_VERSION (revision $REVISION)" >&2 + fi + if [[ -n ${GITHUB_OUTPUT-} ]]; then + echo "bundle-version=$BUNDLE_VERSION" >>"$GITHUB_OUTPUT" + fi echo "Dry run: $DRY_RUN" >&2 if [[ -n ${BUNDLE_METADATA_PATH-} ]]; then echo "Bundle metadata: $BUNDLE_METADATA_PATH" >&2 @@ -186,7 +205,7 @@ main() { cat >build-artifacts/release-bundle-spec.json <&2 # Create the release bundle - run jf release-bundle-create "$BUNDLE_NAME" "$VERSION" \ + run jf release-bundle-create "$BUNDLE_NAME" "$BUNDLE_VERSION" \ --spec build-artifacts/release-bundle-spec.json \ --project="$PROJECT" \ --signing-key="aerospike" @@ -215,7 +234,7 @@ EOF error "failed to build properties string from $BUNDLE_METADATA_PATH" if [[ -n $rb_props ]]; then echo "Applying release bundle properties from bundle metadata (${#rb_props} chars)" >&2 - run jf release-bundle-annotate "$BUNDLE_NAME" "$VERSION" \ + run jf release-bundle-annotate "$BUNDLE_NAME" "$BUNDLE_VERSION" \ --project="$PROJECT" \ --properties="$rb_props" else diff --git a/.github/workflows/create-release-bundle/test-entrypoint.sh b/.github/workflows/create-release-bundle/test-entrypoint.sh index abf654f7..d5331353 100755 --- a/.github/workflows/create-release-bundle/test-entrypoint.sh +++ b/.github/workflows/create-release-bundle/test-entrypoint.sh @@ -169,6 +169,61 @@ fi record_test_result "Test 5: Bundle metadata dotfile path triggers annotate (dry-run)" "$test5_success" +# Test 6: Revision forms the bundle version without changing the release version +echo "" +echo "Test 6: Revision appends to the bundle version (dry-run)" +test6_success=true + +cd "$TEST_DIR" +output=$("$SCRIPT_DIR/entrypoint.sh" --project test-project --build-names "test-build:1728052628123" --bundle-name rev-bundle --version v1.2.3 --revision 4711-2 --dry-run 2>&1) + +if ! echo "$output" | grep -q "jf release-bundle-create rev-bundle v1.2.3-4711-2"; then + test6_success=false +fi +if ! echo "$output" | grep -q "Bundle version: v1.2.3-4711-2 (revision 4711-2)"; then + test6_success=false +fi +if ! echo "$output" | grep -q '"description": "Release for build version v1.2.3"'; then + test6_success=false +fi +if ! echo "$output" | grep -q '"version": "v1.2.3-4711-2"'; then + test6_success=false +fi + +record_test_result "Test 6: Revision appends to the bundle version (dry-run)" "$test6_success" + +# Test 7: Without a revision the bundle version is the release version +echo "" +echo "Test 7: No revision leaves the bundle version unchanged (dry-run)" +test7_success=true + +cd "$TEST_DIR" +output=$("$SCRIPT_DIR/entrypoint.sh" --project test-project --build-names "test-build:1728052628123" --bundle-name norev-bundle --version v1.2.3 --dry-run 2>&1) + +if ! echo "$output" | grep -q "jf release-bundle-create norev-bundle v1.2.3"; then + test7_success=false +fi +if echo "$output" | grep -q "Bundle version:"; then + test7_success=false +fi + +record_test_result "Test 7: No revision leaves the bundle version unchanged (dry-run)" "$test7_success" + +# Test 8: The resolved bundle version is exposed as a step output +echo "" +echo "Test 8: Bundle version is written to GITHUB_OUTPUT" +test8_success=true + +cd "$TEST_DIR" +: >"$TEST_DIR/gh-output" +GITHUB_OUTPUT="$TEST_DIR/gh-output" "$SCRIPT_DIR/entrypoint.sh" --project test-project --build-names "test-build:1728052628123" --bundle-name out-bundle --version v9.9.9 --revision 1-1 --dry-run >/dev/null 2>&1 + +if ! grep -q "^bundle-version=v9.9.9-1-1$" "$TEST_DIR/gh-output"; then + test8_success=false +fi + +record_test_result "Test 8: Bundle version is written to GITHUB_OUTPUT" "$test8_success" + # Summary echo "" echo "Test report: $TEST_REPORT_FILE" diff --git a/.github/workflows/create-release-bundle/tests/bats/test_revision_plumbing.bats b/.github/workflows/create-release-bundle/tests/bats/test_revision_plumbing.bats new file mode 100644 index 00000000..7a349c45 --- /dev/null +++ b/.github/workflows/create-release-bundle/tests/bats/test_revision_plumbing.bats @@ -0,0 +1,38 @@ +#!/usr/bin/env bats + +setup() { + ROOT="${BATS_TEST_DIRNAME}/../../../../.." + ACTION="${ROOT}/.github/actions/create-release-bundle/action.yaml" + WORKFLOW="${ROOT}/.github/workflows/reusable_create-release-bundle.yaml" +} + +@test "the action takes bundle-revision and returns bundle-version" { + run python3 -c " +import yaml +d = yaml.safe_load(open('$ACTION')) +assert 'bundle-revision' in d['inputs'], 'input missing' +assert d['inputs']['bundle-revision'].get('default') == '', 'default is not empty' +assert 'bundle-version' in d.get('outputs', {}), 'output missing' +step = d['runs']['steps'][0]['run'] +assert '--revision' in step, 'revision not passed to the entrypoint' +assert 'github.run_id' in step, 'auto does not resolve to the run' +" + [ "$status" -eq 0 ] +} + +@test "the workflow takes bundle-revision and returns bundle-version" { + run python3 -c " +import yaml +d = yaml.safe_load(open('$WORKFLOW')) +wc = d[True]['workflow_call'] +assert 'bundle-revision' in wc['inputs'], 'input missing' +assert wc['inputs']['bundle-revision'].get('default') == '', 'default is not empty' +assert 'bundle-version' in wc.get('outputs', {}), 'workflow output missing' +job = d['jobs']['create-release-bundle'] +assert 'bundle-version' in job.get('outputs', {}), 'job output missing' +step = [s for s in job['steps'] if s.get('id') == 'create'][0]['run'] +assert '--revision' in step, 'revision not passed to the entrypoint' +assert 'github.run_id' in step, 'auto does not resolve to the run' +" + [ "$status" -eq 0 ] +} diff --git a/.github/workflows/docs/release-bundles.md b/.github/workflows/docs/release-bundles.md index 60a1a7cc..db237395 100644 --- a/.github/workflows/docs/release-bundles.md +++ b/.github/workflows/docs/release-bundles.md @@ -56,7 +56,15 @@ release-bundle: Set `dry-run: true` to validate the configuration and JFrog authentication without actually creating the bundle. In dry-run mode the workflow echoes the commands it would run instead of calling `jf release-bundle-create`. -There is a composite action that may be used for promotion of bundles documented at [Promote Release Bundle Composite Action](https://github.com/aerospike/shared-workflows/blob/main/.github/actions/promote-release-bundle/README.md). The `promote-release-bundle` action accepts `include-repos` and `exclude-repos` (semicolon-separated repo lists, e.g. `my-project-deb-dev-local;my-project-rpm-dev-local`) to scope which repositories are promoted. With neither set, all repositories in the bundle are promoted. +### Bundle revisions + +The bundle version defaults to the release version, so a rebuild of a release collides with the bundle the first build created. Pass `bundle-revision: auto` to `reusable_create-release-bundle.yaml` to give each build its own bundle version (`1.2.3--`) while the artifacts keep the clean release version. Two builds of one release can then coexist, which is what makes superseding a rejected build possible. Use the workflow's `bundle-version` output as the version to promote. + +### Promoting a bundle + +Use `reusable_promote-release-bundle.yaml`. It advances a bundle one stage and enforces the promotion rules: a version must already be at the stage before the target, promoting into a stage that holds a different version fails rather than overwriting, replacing an incumbent requires `supersede: true` with a reason, and supersede is refused at PREVIEW, INTERNAL and PROD. Passing `gh-environment` attaches a GitHub environment to the promotion job so its reviewers are the gate owner, which a composite action cannot do. See [Promote Release Bundle](../promote-release-bundle/README.md). + +The lower-level [promote-release-bundle action](https://github.com/aerospike/shared-workflows/blob/main/.github/actions/promote-release-bundle/README.md) promotes without any of those checks. Both it and the workflow accept `include-repos` and `exclude-repos` (semicolon-separated repo lists, e.g. `my-project-deb-dev-local;my-project-rpm-dev-local`) to scope which repositories are promoted. With neither set, all repositories in the bundle are promoted. ### Deleting a bundle before re-deploy diff --git a/.github/workflows/lib/jfrog-lifecycle.sh b/.github/workflows/lib/jfrog-lifecycle.sh new file mode 100644 index 00000000..2d808422 --- /dev/null +++ b/.github/workflows/lib/jfrog-lifecycle.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# jfrog-lifecycle.sh - Reads from the JFrog Lifecycle (Release Bundle v2) API. + +# jf rt curl is Artifactory-scoped and 404s on a /lifecycle path. +jfrog_lifecycle_get() { + local path="$1" config base token + config=$(jf config export) || { + echo "Error: could not read the JFrog CLI configuration" >&2 + return 1 + } + base=$(printf '%s' "$config" | base64 -d | jq -r '.url // empty') + token=$(printf '%s' "$config" | base64 -d | jq -rj '.accessToken // empty') + if [[ -z $base || -z $token ]]; then + echo "Error: the JFrog CLI has no platform URL and access token configured" >&2 + return 1 + fi + + # The header goes in a config file so the token stays out of argv and xtrace. + curl -sS --fail-with-body --config - "${base%/}/${path#/}" <&2 + return 1 + } + if ! echo "$out" | jq -e 'has("promotions")' >/dev/null 2>&1; then + echo "Error: unexpected promotion records response for ${bundle_name}: ${out}" >&2 + return 1 + fi + echo "$out" +} + +# Field names vary across JFrog responses, so read every spelling we have seen. +jfrog_promotion_stages() { + local records="$1" version="$2" + echo "$records" | jq -r --arg version "$version" ' + [(.promotions // [])[]? + | select((.status // "COMPLETED") == "COMPLETED") + | select((.release_bundle_version // .releaseBundleVersion // .version // "") == $version) + | (.environment // .target_environment // .targetEnvironment // empty)] + | unique | .[]' +} + +jfrog_versions_at_stage() { + local records="$1" stage="$2" + echo "$records" | jq -r --arg stage "$stage" ' + [(.promotions // [])[]? + | select((.status // "COMPLETED") == "COMPLETED") + | select((.environment // .target_environment // .targetEnvironment // "") == $stage) + | (.release_bundle_version // .releaseBundleVersion // .version // empty)] + | unique | .[]' +} diff --git a/.github/workflows/promote-release-bundle/README.md b/.github/workflows/promote-release-bundle/README.md new file mode 100644 index 00000000..b18af70d --- /dev/null +++ b/.github/workflows/promote-release-bundle/README.md @@ -0,0 +1,89 @@ +# Promote Release Bundle + +Advances a JFrog release bundle one promotion stage forward. Refuses to overwrite a +stage that already holds a different version unless a supersede is explicitly requested. + +Use `reusable_promote-release-bundle.yaml` rather than calling +`jf release-bundle-promote` directly. The guards below live in the workflow, and the +GitHub environment approval is only available to a callable workflow. + +## Usage + +```yaml +promote-to-test: + uses: aerospike/shared-workflows/.github/workflows/reusable_promote-release-bundle.yaml@ # v3.8.0 + with: + gh-workflows-ref: v3.8.0 + jf-bundle-name: my-release + jf-project: my-project + version: 1.2.3 + target-stage: TEST + gh-environment: promote-test + secrets: inherit +``` + +Replacing a build that failed a gate: + +```yaml +supersede-at-test: + uses: aerospike/shared-workflows/.github/workflows/reusable_promote-release-bundle.yaml@ # v3.8.0 + with: + gh-workflows-ref: v3.8.0 + jf-bundle-name: my-release + jf-project: my-project + version: 1.2.3-1794531-2 # create-release-bundle bundle-version output + target-stage: TEST + gh-environment: promote-test + supersede: true + supersede-reason: rejected by QE, bad default in shipped config + secrets: inherit +``` + +## What it enforces + +| Rule | Behaviour | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Stage order | A version must already be at the stage directly before the target. DEV needs no predecessor. | +| No implicit overwrite | Promoting into a stage that holds a different version fails, naming the incumbent. | +| Explicit supersede | Replacing an incumbent requires `supersede: true` and a `supersede-reason`. | +| Released stages are final | Supersede is refused at PREVIEW, INTERNAL and PROD. Ship the fix as a new version. | +| Gate owner approves | `supersede: true` requires `gh-environment`, whose reviewers approve the replacement. | +| Recorded | Both the superseding and superseded versions are annotated with the event, and it is written to the run summary. | + +Stage order is a graph, not a line. PREVIEW and INTERNAL both follow STAGE, and PROD +follows PREVIEW. + +## Superseding + +A supersede removes the incumbent's promotion to the target stage only. Other stages keep +their promotions and keep serving the incumbent, so nothing downstream goes dark while a +replacement is prepared. + +The incumbent's bundle keeps its own signed copy of every artifact, so it stays promotable +and rolling back is promoting it again. + +Never delete a release bundle to clear a promotion conflict. The bundle holds the version's +only durable copy and its provenance. + +## The supersede record + +Promotion records in Artifactory are current state, so removing a promotion erases it with +no tombstone. The event is therefore written as bundle properties, which persist with the +bundle: + +- On the superseding version: `supersede.replaced`, `supersede.stage`, `supersede.at`, + `supersede.actor`, `supersede.reason` +- On the superseded version: `supersede.replacedBy` and the same four fields + +A version superseded at more than one stage keeps only the most recent set of properties. +The run summary carries the full per-run trail. + +## Inputs + +See `reusable_promote-release-bundle.yaml` for the full input list and defaults. + +## Tests + +```bash +bats .github/workflows/promote-release-bundle/tests/bats/ +``` diff --git a/.github/workflows/promote-release-bundle/entrypoint.sh b/.github/workflows/promote-release-bundle/entrypoint.sh new file mode 100755 index 00000000..7ba01550 --- /dev/null +++ b/.github/workflows/promote-release-bundle/entrypoint.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +set -euo pipefail + +export PS4='+($LINENO): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }' +trap 'handle_error ${LINENO}' ERR + +# shellcheck disable=SC2317 +handle_error() { + local exit_code=$? + local line_number=$1 + echo "Error: Command failed with exit code $exit_code at line $line_number" >&2 + exit 1 +} + +error() { + local reason="${1-}" + if [[ -n $reason ]]; then + echo "Error: $reason" >&2 + else + echo "Error" >&2 + fi + exit 1 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../lib/jfrog-lifecycle.sh" + +BUNDLE_NAME="" +VERSION="" +TARGET_STAGE="" +PROJECT="" +INCLUDE_REPOS="" +EXCLUDE_REPOS="" +SUPERSEDE="false" +SUPERSEDE_REASON="" +ACTOR="${GITHUB_ACTOR-unknown}" +DRY_RUN="false" + +# Not a linear order: PREVIEW and INTERNAL both follow STAGE. +stage_predecessor() { + case "$1" in + DEV) echo "" ;; + TEST) echo "DEV" ;; + STAGE) echo "TEST" ;; + PREVIEW) echo "STAGE" ;; + INTERNAL) echo "STAGE" ;; + PROD) echo "PREVIEW" ;; + *) return 1 ;; + esac +} + +stage_is_frozen() { + case "$1" in + PREVIEW | INTERNAL | PROD) return 0 ;; + *) return 1 ;; + esac +} + +show_help() { + echo "Usage: $0 --bundle-name --version --target-stage --project [OPTIONS]" >&2 + echo "" >&2 + echo "Promote a JFrog release bundle one stage forward, refusing to overwrite an" >&2 + echo "occupied stage unless a supersede is explicitly requested." >&2 + echo "" >&2 + echo "Required Arguments:" >&2 + echo " --bundle-name Release bundle name" >&2 + echo " --version Release bundle version to promote" >&2 + echo " --target-stage DEV, TEST, STAGE, PREVIEW, INTERNAL or PROD" >&2 + echo " --project JFrog project key" >&2 + echo "" >&2 + echo "Options:" >&2 + echo " --supersede Replace the version currently at the target stage." >&2 + echo " Refused for PREVIEW, INTERNAL and PROD." >&2 + echo " --supersede-reason Why the incumbent is being replaced. Required with" >&2 + echo " --supersede." >&2 + echo " --include-repos Semicolon-separated repos to include" >&2 + echo " --exclude-repos Semicolon-separated repos to exclude" >&2 + echo " --actor Who is promoting. Defaults to GITHUB_ACTOR." >&2 + echo " --dry-run Print the JFrog commands without running them" >&2 + echo " --help Show this help" >&2 +} + +run() { + if [[ $DRY_RUN == "true" ]]; then + local green='\033[0;32m' + local reset='\033[0m' + echo -e "${green} $*${reset}" >&2 + else + "$@" + fi +} + +# Semicolons separate properties and newlines end them, so neither can survive +# inside a value. +sanitize_property_value() { + printf '%s' "$1" | tr ';\n\r' ', ' | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//' +} + +version_is_at_stage() { + local records="$1" version="$2" stage="$3" + jfrog_versions_at_stage "$records" "$stage" | grep -Fxq "$version" +} + +annotate() { + local version="$1" props="$2" + run jf release-bundle-annotate "$BUNDLE_NAME" "$version" \ + --project="$PROJECT" \ + --properties="$props" +} + +record_supersede() { + local incumbent="$1" stamp="$2" reason="$3" + + annotate "$VERSION" \ + "supersede.replaced=${incumbent};supersede.stage=${TARGET_STAGE};supersede.at=${stamp};supersede.actor=${ACTOR};supersede.reason=${reason}" + annotate "$incumbent" \ + "supersede.replacedBy=${VERSION};supersede.stage=${TARGET_STAGE};supersede.at=${stamp};supersede.actor=${ACTOR};supersede.reason=${reason}" + + if [[ -n ${GITHUB_STEP_SUMMARY-} ]]; then + { + echo "### Superseded at ${TARGET_STAGE}" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Bundle | ${BUNDLE_NAME} |" + echo "| Replaced | ${incumbent} |" + echo "| Replaced by | ${VERSION} |" + echo "| Stage | ${TARGET_STAGE} |" + echo "| At | ${stamp} |" + echo "| Actor | ${ACTOR} |" + echo "| Reason | ${reason} |" + } >>"$GITHUB_STEP_SUMMARY" + fi +} + +promote() { + local args=("$BUNDLE_NAME" "$VERSION" "$TARGET_STAGE" --project="$PROJECT") + [[ -n $INCLUDE_REPOS ]] && args+=(--include-repos="$INCLUDE_REPOS") + [[ -n $EXCLUDE_REPOS ]] && args+=(--exclude-repos="$EXCLUDE_REPOS") + run jf release-bundle-promote "${args[@]}" +} + +main() { + while [[ $# -gt 0 ]]; do + case "$1" in + --bundle-name) + BUNDLE_NAME="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --target-stage) + TARGET_STAGE="$2" + shift 2 + ;; + --project) + PROJECT="$2" + shift 2 + ;; + --include-repos) + INCLUDE_REPOS="$2" + shift 2 + ;; + --exclude-repos) + EXCLUDE_REPOS="$2" + shift 2 + ;; + --supersede-reason) + SUPERSEDE_REASON="$2" + shift 2 + ;; + --actor) + ACTOR="$2" + shift 2 + ;; + --supersede) + SUPERSEDE="true" + shift + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + --help) + show_help + exit 0 + ;; + *) error "unknown argument: $1" ;; + esac + done + + [[ -n $BUNDLE_NAME ]] || error "--bundle-name is required" + [[ -n $VERSION ]] || error "--version is required" + [[ -n $TARGET_STAGE ]] || error "--target-stage is required" + [[ -n $PROJECT ]] || error "--project is required" + command -v jq >/dev/null 2>&1 || error "jq is required" + + TARGET_STAGE="${TARGET_STAGE^^}" + local predecessor + predecessor=$(stage_predecessor "$TARGET_STAGE") || + error "unknown target stage: ${TARGET_STAGE}. Expected DEV, TEST, STAGE, PREVIEW, INTERNAL or PROD" + + if [[ $SUPERSEDE == "true" && -z $SUPERSEDE_REASON ]]; then + error "--supersede-reason is required with --supersede" + fi + + local records + records=$(jfrog_promotion_records "$BUNDLE_NAME" "$PROJECT") || + error "cannot promote without the promotion records for ${BUNDLE_NAME}" + + if version_is_at_stage "$records" "$VERSION" "$TARGET_STAGE"; then + echo "${BUNDLE_NAME}/${VERSION} is already promoted to ${TARGET_STAGE}. Nothing to do." >&2 + exit 0 + fi + + if [[ -n $predecessor ]] && ! version_is_at_stage "$records" "$VERSION" "$predecessor"; then + error "${BUNDLE_NAME}/${VERSION} is not promoted to ${predecessor}, so it cannot advance to ${TARGET_STAGE}" + fi + + local incumbents + incumbents=$(jfrog_versions_at_stage "$records" "$TARGET_STAGE" | grep -Fxv "$VERSION" || true) + + if [[ -n $incumbents ]]; then + local incumbent_list + incumbent_list=$(echo "$incumbents" | paste -sd, -) + + if [[ $SUPERSEDE != "true" ]]; then + error "${TARGET_STAGE} already holds ${BUNDLE_NAME}/${incumbent_list}. Pass --supersede with --supersede-reason to replace it" + fi + + if stage_is_frozen "$TARGET_STAGE"; then + error "${TARGET_STAGE} is a released stage and cannot be superseded. ${BUNDLE_NAME}/${incumbent_list} is published; ship the fix as a new version" + fi + + local stamp reason + stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) + reason=$(sanitize_property_value "$SUPERSEDE_REASON") + + while IFS= read -r incumbent; do + [[ -n $incumbent ]] || continue + echo "Superseding ${BUNDLE_NAME}/${incumbent} at ${TARGET_STAGE}" >&2 + run jf release-bundle-delete-local "$BUNDLE_NAME" "$incumbent" "$TARGET_STAGE" \ + --project="$PROJECT" --quiet + record_supersede "$incumbent" "$stamp" "$reason" + done <<<"$incumbents" + fi + + promote + echo "Promoted ${BUNDLE_NAME}/${VERSION} to ${TARGET_STAGE}" >&2 +} + +main "$@" diff --git a/.github/workflows/promote-release-bundle/tests/bats/test_promote_supersede.bats b/.github/workflows/promote-release-bundle/tests/bats/test_promote_supersede.bats new file mode 100644 index 00000000..608d7d90 --- /dev/null +++ b/.github/workflows/promote-release-bundle/tests/bats/test_promote_supersede.bats @@ -0,0 +1,173 @@ +#!/usr/bin/env bats + +setup() { + ENTRYPOINT="${BATS_TEST_DIRNAME}/../../entrypoint.sh" + export JF_CALLS="${BATS_TEST_TMPDIR}/jf-calls.log" + export JF_RECORDS="${BATS_TEST_TMPDIR}/records.json" + : >"$JF_CALLS" + + mkdir -p "${BATS_TEST_TMPDIR}/bin" + cat >"${BATS_TEST_TMPDIR}/bin/jf" <<'STUB' +#!/usr/bin/env bash +if [[ $1 == "config" && $2 == "export" ]]; then + printf '%s' '{"url":"https://jfrog.test/","accessToken":"tkn"}' | base64 -w0 + exit 0 +fi +echo "jf $*" >>"$JF_CALLS" +exit 0 +STUB + chmod +x "${BATS_TEST_TMPDIR}/bin/jf" + + cat >"${BATS_TEST_TMPDIR}/bin/curl" <<'STUB' +#!/usr/bin/env bash +cat >/dev/null +if [[ -n ${CURL_FAILS-} ]]; then + echo "curl: (22) HTTP 503" >&2 + exit 22 +fi +cat "$JF_RECORDS" +exit 0 +STUB + chmod +x "${BATS_TEST_TMPDIR}/bin/curl" + export PATH="${BATS_TEST_TMPDIR}/bin:$PATH" + cd "$BATS_TEST_TMPDIR" || exit 1 +} + +# Each entry is "version:stage". +records() { + local entries=("$@") json="[]" e version stage + for e in "${entries[@]}"; do + version="${e%%:*}" + stage="${e##*:}" + json=$(echo "$json" | jq --arg v "$version" --arg s "$stage" \ + '. + [{release_bundle_version: $v, environment: $s, status: "COMPLETED"}]') + done + echo "{\"promotions\": $json}" >"$JF_RECORDS" +} + +promote() { + run bash "$ENTRYPOINT" --bundle-name my-app --project test --actor tester "$@" +} + +@test "promotes into an empty stage" { + records "1.2.3:DEV" + promote --version 1.2.3 --target-stage TEST + [ "$status" -eq 0 ] + grep -q "release-bundle-promote my-app 1.2.3 TEST" "$JF_CALLS" +} + +@test "refuses to overwrite an occupied stage and names the incumbent" { + records "1.2.3:DEV" "1.2.3:TEST" "1.2.4:DEV" + promote --version 1.2.4 --target-stage TEST + [ "$status" -ne 0 ] + [[ $output == *"already holds my-app/1.2.3"* ]] + [[ $output == *"--supersede"* ]] + ! grep -q "release-bundle-promote" "$JF_CALLS" +} + +@test "supersede is not the default" { + records "1.2.3:DEV" "1.2.3:TEST" "1.2.4:DEV" + promote --version 1.2.4 --target-stage TEST + [ "$status" -ne 0 ] + ! grep -q "release-bundle-delete-local" "$JF_CALLS" +} + +@test "supersede requires a reason" { + records "1.2.3:DEV" "1.2.3:TEST" "1.2.4:DEV" + promote --version 1.2.4 --target-stage TEST --supersede + [ "$status" -ne 0 ] + [[ $output == *"--supersede-reason is required"* ]] +} + +@test "supersede removes the incumbent from the target stage only" { + records "1.2.3:DEV" "1.2.3:TEST" "1.2.3:STAGE" "1.2.4:DEV" "1.2.4:TEST" + promote --version 1.2.4 --target-stage STAGE --supersede --supersede-reason "failed integration" + [ "$status" -eq 0 ] + grep -q "release-bundle-delete-local my-app 1.2.3 STAGE" "$JF_CALLS" + ! grep -q "release-bundle-delete-local my-app 1.2.3 TEST" "$JF_CALLS" + grep -q "release-bundle-promote my-app 1.2.4 STAGE" "$JF_CALLS" +} + +@test "supersede is refused at PREVIEW, INTERNAL and PROD" { + for stage in PREVIEW INTERNAL; do + records "1.2.3:STAGE" "1.2.3:$stage" "1.2.4:STAGE" + promote --version 1.2.4 --target-stage "$stage" --supersede --supersede-reason "late fix" + [ "$status" -ne 0 ] + [[ $output == *"released stage"* ]] + [[ $output == *"new version"* ]] + done + + records "1.2.3:PREVIEW" "1.2.3:PROD" "1.2.4:PREVIEW" + promote --version 1.2.4 --target-stage PROD --supersede --supersede-reason "late fix" + [ "$status" -ne 0 ] + [[ $output == *"released stage"* ]] +} + +@test "refuses a promotion that skips a stage" { + records "1.2.3:DEV" + promote --version 1.2.3 --target-stage STAGE + [ "$status" -ne 0 ] + [[ $output == *"not promoted to TEST"* ]] + ! grep -q "release-bundle-promote" "$JF_CALLS" +} + +@test "records the supersede on both revisions" { + records "1.2.3:DEV" "1.2.3:TEST" "1.2.4:DEV" + promote --version 1.2.4 --target-stage TEST --supersede --supersede-reason "bad config default" + [ "$status" -eq 0 ] + + grep -q "release-bundle-annotate my-app 1.2.4 .*supersede.replaced=1.2.3" "$JF_CALLS" + grep -q "release-bundle-annotate my-app 1.2.3 .*supersede.replacedBy=1.2.4" "$JF_CALLS" + grep -q "supersede.stage=TEST" "$JF_CALLS" + grep -q "supersede.reason=bad config default" "$JF_CALLS" + grep -qE "supersede.at=[0-9]{4}-[0-9]{2}-[0-9]{2}T" "$JF_CALLS" +} + +@test "a semicolon in the reason cannot split the property string" { + records "1.2.3:DEV" "1.2.3:TEST" "1.2.4:DEV" + promote --version 1.2.4 --target-stage TEST --supersede \ + --supersede-reason "broke;supersede.actor=someone-else" + [ "$status" -eq 0 ] + # The text survives inside the reason value; what must not survive is the + # delimiter that would make it a property of its own. + ! grep -q ";supersede.actor=someone-else" "$JF_CALLS" + grep -q "supersede.actor=tester" "$JF_CALLS" +} + +@test "promoting to a stage the version already occupies is a no-op" { + records "1.2.3:DEV" "1.2.3:TEST" + promote --version 1.2.3 --target-stage TEST + [ "$status" -eq 0 ] + [[ $output == *"already promoted"* ]] + ! grep -q "release-bundle-promote" "$JF_CALLS" +} + +@test "rejects an unknown stage" { + records "1.2.3:DEV" + promote --version 1.2.3 --target-stage QA + [ "$status" -ne 0 ] + [[ $output == *"unknown target stage"* ]] +} + +@test "DEV needs no predecessor" { + records + promote --version 1.2.3 --target-stage DEV + [ "$status" -eq 0 ] + grep -q "release-bundle-promote my-app 1.2.3 DEV" "$JF_CALLS" +} + +@test "an unreadable records response stops the promotion" { + records "1.2.3:DEV" + CURL_FAILS=1 promote --version 1.2.3 --target-stage TEST + [ "$status" -ne 0 ] + [[ $output == *"could not read promotion records"* ]] + ! grep -q "release-bundle-promote" "$JF_CALLS" +} + +@test "a records response without a promotions key stops the promotion" { + echo '{"unexpected": true}' >"$JF_RECORDS" + promote --version 1.2.3 --target-stage TEST + [ "$status" -ne 0 ] + [[ $output == *"unexpected promotion records response"* ]] + ! grep -q "release-bundle-promote" "$JF_CALLS" +} diff --git a/.github/workflows/promote-release-bundle/tests/bats/test_workflow_gate.bats b/.github/workflows/promote-release-bundle/tests/bats/test_workflow_gate.bats new file mode 100644 index 00000000..1650e7e0 --- /dev/null +++ b/.github/workflows/promote-release-bundle/tests/bats/test_workflow_gate.bats @@ -0,0 +1,39 @@ +#!/usr/bin/env bats + +setup() { + WORKFLOW="${BATS_TEST_DIRNAME}/../../../reusable_promote-release-bundle.yaml" +} + +query() { + python3 -c " +import yaml, sys +d = yaml.safe_load(open('$WORKFLOW')) +print($1) +" +} + +@test "the promotion job is attached to the gh-environment input" { + result=$(query "d['jobs']['promote-release-bundle'].get('environment')") + [ "$result" = '${{ inputs.gh-environment }}' ] +} + +@test "supersede is refused without a gh-environment" { + result=$(query "[s for s in d['jobs']['promote-release-bundle']['steps'] if s.get('if') == 'inputs.supersede']") + [[ $result == *"gh-environment"* ]] + [[ $result == *"exit 1"* ]] +} + +@test "supersede defaults to false" { + result=$(query "d[True]['workflow_call']['inputs']['supersede']['default']") + [ "$result" = "False" ] +} + +@test "the entrypoint is run from the pinned shared-workflows checkout" { + steps=$(query "d['jobs']['promote-release-bundle']['steps']") + [[ $steps == *"gh-checkout-path"* ]] + [[ $steps != *"uses': './"* ]] + + checkout=$(query "[s for s in d['jobs']['promote-release-bundle']['steps'] if 'checkout' in str(s.get('uses',''))]") + [[ $checkout == *"gh-workflows-ref"* ]] + [[ $checkout == *"aerospike/shared-workflows"* ]] +} diff --git a/.github/workflows/reusable_create-release-bundle.yaml b/.github/workflows/reusable_create-release-bundle.yaml index 521a4ce5..11f0c03e 100644 --- a/.github/workflows/reusable_create-release-bundle.yaml +++ b/.github/workflows/reusable_create-release-bundle.yaml @@ -44,6 +44,15 @@ on: required: false type: string default: "" + bundle-revision: + description: | + Appended to `version` to form the bundle version, so a rebuild of a release becomes a + new bundle revision rather than colliding with the bundle the first build created. The + artifacts keep the clean release version. Set to `auto` to use the run id and attempt. + Leave unset for a bundle version equal to the release version. + required: false + type: string + default: "" gh-bundle-metadata-artifact-name: description: | When set, downloads this GitHub Actions artifact (e.g. deploy job output `bundle-metadata-artifact-name` @@ -79,6 +88,11 @@ on: type: string default: ubuntu-22.04 + outputs: + bundle-version: + description: Bundle version that was created, including the revision when one was requested + value: ${{ jobs.create-release-bundle.outputs.bundle-version }} + permissions: contents: read id-token: write @@ -86,6 +100,8 @@ permissions: jobs: create-release-bundle: runs-on: ${{ inputs.runs-on }} + outputs: + bundle-version: ${{ steps.create.outputs.bundle-version }} permissions: contents: read id-token: write @@ -168,6 +184,7 @@ jobs: fi - name: Create Release Bundle + id: create shell: bash run: | entrypoint="${{ inputs.gh-checkout-path }}/.github/workflows/create-release-bundle/entrypoint.sh" @@ -185,10 +202,19 @@ jobs: if [[ -n "$bm" ]]; then meta_arg=(--bundle-metadata "$bm") fi + revision_arg=() + revision="${{ inputs.bundle-revision }}" + if [[ "$revision" == "auto" ]]; then + revision="${{ github.run_id }}-${{ github.run_attempt }}" + fi + if [[ -n "$revision" ]]; then + revision_arg=(--revision "$revision") + fi "$entrypoint" \ --project "${{ inputs.jf-project }}" \ --build-names "${{ inputs.jf-build-names }}" \ --bundle-name "${{ inputs.jf-bundle-name }}" \ --version "${{ inputs.version }}" \ "${meta_arg[@]}" \ + "${revision_arg[@]}" \ $dry_run_arg diff --git a/.github/workflows/reusable_promote-release-bundle.yaml b/.github/workflows/reusable_promote-release-bundle.yaml new file mode 100644 index 00000000..744b5e1d --- /dev/null +++ b/.github/workflows/reusable_promote-release-bundle.yaml @@ -0,0 +1,178 @@ +name: Promote Release Bundle + +on: + workflow_call: + inputs: + # Required inputs + jf-bundle-name: + description: Name of the release bundle to promote + required: true + type: string + jf-project: + description: JFrog Artifactory project name + required: true + type: string + target-stage: + description: | + Promotion stage to advance to: DEV, TEST, STAGE, PREVIEW, INTERNAL or PROD. + Promotion may only advance from the stage directly before the target. + required: true + type: string + version: + description: Version of the release bundle to promote + required: true + type: string + gh-workflows-ref: + description: | + Git reference for shared-workflows checkout. Should match the version in your 'uses:' line. + GitHub Actions doesn't expose the called workflow's ref, so this is required. + See: .github/workflows/docs/why-gh-workflows-ref.md + required: true + type: string + + # Optional inputs + dry-run: + description: Whether to run in dry-run mode + required: false + type: boolean + default: false + exclude-repos: + description: Semicolon-separated list of target repos to exclude from promotion + required: false + type: string + default: "" + gh-checkout-path: + description: Directory to checkout the shared-workflows repository into + required: false + type: string + default: shared-workflows + gh-environment: + description: | + GitHub environment to attach to the promotion job. Its reviewers are the gate + owner for this promotion, which is the one protection a callable workflow can + apply that a composite action cannot. Required when supersede is true. + required: false + type: string + default: "" + include-repos: + description: Semicolon-separated list of target repos to include in promotion (limits scope) + required: false + type: string + default: "" + jf-url: + description: JFrog Artifactory URL + required: false + type: string + default: https://artifact.aerospike.io + oidc-audience: + description: OIDC audience + required: false + type: string + default: aerospike + oidc-provider-name: + description: OIDC provider name + required: false + type: string + default: gh-aerospike + runs-on: + description: The runner to use + required: false + type: string + default: ubuntu-22.04 + supersede: + description: | + Replace the version currently promoted to the target stage. Promotion into an + occupied stage fails unless this is set. Refused for PREVIEW, INTERNAL and PROD, + where a fix ships as a new version. + required: false + type: boolean + default: false + supersede-reason: + description: Why the incumbent is being replaced. Required when supersede is true. + required: false + type: string + default: "" + +permissions: + contents: read + id-token: write + +jobs: + promote-release-bundle: + runs-on: ${{ inputs.runs-on }} + environment: ${{ inputs.gh-environment }} + permissions: + contents: read + id-token: write + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + + - name: Require a gate owner for supersede + if: inputs.supersede + shell: bash + run: | + if [[ -z "${{ inputs.gh-environment }}" ]]; then + echo "Error: supersede requires gh-environment so the gate owner approves replacing the incumbent." >&2 + exit 1 + fi + + - name: Checkout shared-workflows repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: aerospike/shared-workflows + ref: ${{ inputs.gh-workflows-ref }} + path: ${{ inputs.gh-checkout-path }} + fetch-depth: 1 + + - name: Setup JFrog CLI + uses: step-security/setup-jfrog-cli@53aeacb709fce45d0baf69a13ec3342f7e2d71a9 # v5.1.0 + env: + JF_URL: ${{ inputs.jf-url }} + JF_PROJECT: ${{ inputs.jf-project }} + with: + oidc-provider-name: ${{ inputs.oidc-provider-name }} + oidc-audience: ${{ inputs.oidc-audience }} + + - name: Check JFrog Configuration + run: | + jf c show + jf rt ping + + - name: Promote Release Bundle + shell: bash + env: + GH_ACTOR: ${{ github.actor }} + SUPERSEDE_REASON: ${{ inputs.supersede-reason }} + run: | + set -euo pipefail + entrypoint="${{ inputs.gh-checkout-path }}/.github/workflows/promote-release-bundle/entrypoint.sh" + if [[ ! -f "$entrypoint" ]]; then + echo "Error: entrypoint not found at $entrypoint" >&2 + exit 1 + fi + chmod +x "$entrypoint" + + args=( + --bundle-name "${{ inputs.jf-bundle-name }}" + --version "${{ inputs.version }}" + --target-stage "${{ inputs.target-stage }}" + --project "${{ inputs.jf-project }}" + --actor "$GH_ACTOR" + ) + if [[ -n "${{ inputs.include-repos }}" ]]; then + args+=(--include-repos "${{ inputs.include-repos }}") + fi + if [[ -n "${{ inputs.exclude-repos }}" ]]; then + args+=(--exclude-repos "${{ inputs.exclude-repos }}") + fi + if [[ "${{ inputs.supersede }}" == "true" ]]; then + args+=(--supersede --supersede-reason "$SUPERSEDE_REASON") + fi + if [[ "${{ inputs.dry-run }}" == "true" ]]; then + args+=(--dry-run) + fi + + "$entrypoint" "${args[@]}" diff --git a/.github/workflows/test_promote-release-bundle-workflow.yaml b/.github/workflows/test_promote-release-bundle-workflow.yaml new file mode 100644 index 00000000..52c2a7a1 --- /dev/null +++ b/.github/workflows/test_promote-release-bundle-workflow.yaml @@ -0,0 +1,53 @@ +name: Test Promote Release Bundle Workflow +on: + workflow_dispatch: + pull_request: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + # dry-run must stay true: this runs against real JFrog. + test-promote-release-bundle-workflow: + uses: ./.github/workflows/reusable_promote-release-bundle.yaml + with: + gh-workflows-ref: ${{ github.sha }} + jf-project: test + jf-bundle-name: test-promote-guard-probe + version: v1.0.0 + target-stage: DEV + jf-url: https://artifact.aerospike.io + oidc-provider-name: gh-aerospike + oidc-audience: aerospike + dry-run: true + + run-tests: + runs-on: ubuntu-22.04 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install jq -y + + - name: Setup bats + uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 + with: + bats-version: 1.12.0 + support-install: false + assert-install: false + detik-install: false + file-install: false + + - name: Promotion guard tests + run: bats .github/workflows/promote-release-bundle/tests/bats/ + + - name: Delete guard tests + run: bats .github/actions/delete-release-bundle/tests/bats/ diff --git a/CLAUDE.md b/CLAUDE.md index f30db726..9b993039 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,15 +33,16 @@ Two approaches are supported as first-class consumer paths: Pick whichever fits the use case. The orchestrated path is generally lower-maintenance for new consumers; the composable path is the right answer when the orchestrator's opinions don't match. Both are supported, neither is a fallback. -| Workflow | Purpose | -| ------------------------------------- | --------------------------------------------------------------- | -| `reusable_artifacts-cicd.yaml` | Orchestrated build, sign, deploy | -| `reusable_docker-build-deploy.yaml` | Multi-arch OCI images with attestations | -| `reusable_create-release-bundle.yaml` | JFrog release bundles (combines artifact + docker outputs) | -| `reusable_execute-build.yaml` | Composable. Run arbitrary build script, upload artifacts | -| `reusable_sign-artifacts.yaml` | Composable. GPG sign deb/rpm/generic/.tgz, SSL.com sign nupkg | -| `reusable_deploy-artifacts.yaml` | Composable. Upload to JFrog Artifactory (auto-routes by type) | -| `reusable_notify-slack.yaml` | Post Block Kit Slack alerts (info/fail/success/blocked/warning) | +| Workflow | Purpose | +| -------------------------------------- | --------------------------------------------------------------- | +| `reusable_artifacts-cicd.yaml` | Orchestrated build, sign, deploy | +| `reusable_docker-build-deploy.yaml` | Multi-arch OCI images with attestations | +| `reusable_create-release-bundle.yaml` | JFrog release bundles (combines artifact + docker outputs) | +| `reusable_promote-release-bundle.yaml` | Promote a bundle one stage; refuses overwrite, gates supersede | +| `reusable_execute-build.yaml` | Composable. Run arbitrary build script, upload artifacts | +| `reusable_sign-artifacts.yaml` | Composable. GPG sign deb/rpm/generic/.tgz, SSL.com sign nupkg | +| `reusable_deploy-artifacts.yaml` | Composable. Upload to JFrog Artifactory (auto-routes by type) | +| `reusable_notify-slack.yaml` | Post Block Kit Slack alerts (info/fail/success/blocked/warning) | ### Slack notification actions