diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 535dac0..cd834ab 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,6 +9,35 @@ on: - cron: '0 0 * * *' # Runs at midnight UTC every day jobs: + test-default-datadog-ci-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check that the default datadog-ci version is pinned + run: | + set -euo pipefail + default_version=$(ruby -ryaml -e 'puts YAML.load_file("action.yaml").fetch("inputs").fetch("datadog-ci-version").fetch("default")') + if [[ ! "$default_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Expected datadog-ci-version default to be an exact release tag, got '$default_version'" + exit 1 + fi + - name: Check local release helper scripts + run: | + set -euo pipefail + bash -n scripts/bump-datadog-ci-version.sh + bash -n scripts/create-datadog-ci-bump-pr.sh + bash -n scripts/release-datadog-ci-bump.sh + + tmpdir=$(mktemp -d) + cp action.yaml README.md "$tmpdir"/ + mkdir "$tmpdir/scripts" + cp scripts/bump-datadog-ci-version.sh "$tmpdir/scripts"/ + cd "$tmpdir" + + scripts/bump-datadog-ci-version.sh v5.99.1 + ruby -ryaml -e 'abort unless YAML.load_file("action.yaml").fetch("inputs").fetch("datadog-ci-version").fetch("default") == "v5.99.1"' + grep -q '| `datadog-ci-version` |.*| `v5.99.1`' README.md + test: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index e6b05c6..60d2604 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,15 @@ The action has the following options: | `service` | Service name to use with the uploaded test results. | False | | | `env` | Optional environment to add to the tests | False | | | `logs` | When set to "true" enables forwarding content from the XML reports as Logs. The content inside ``, ``, and `` is collected as logs. Logs from elements inside a `` are automatically connected to the test. | False | | -| `datadog-ci-version` | Version of datadog-ci to install. Use a major version like `v5` to get the latest release within that major version, or a specific tag like `v5.6.0` to pin. Legacy npm semver syntax (`^`, `~`, `>=`, `latest`) is still supported but deprecated. | False | `v5` | +| `datadog-ci-version` | Version of datadog-ci to install. Defaults to the pinned datadog-ci release shipped with this action version. Use a major version like `v5` to get the latest release within that major version, or a specific tag like `v5.6.0` to pin. Legacy npm semver syntax (`^`, `~`, `>=`, `latest`) is still supported but deprecated. | False | `v5.14.0` | | `github-token` | GitHub token to use for authenticated datadog-ci release resolution. Defaults to the workflow `github.token` when omitted. | False | `github.token` | | `extra-args` | Extra args to be passed to the datadog-ci junit upload command. | False | | -This action passes the workflow `github.token` to the install step by default. That is primarily useful when `datadog-ci-version` uses a floating release selector such as `v5`, because GitHub release resolution can then be authenticated. To avoid depending on latest-within-major resolution, pin an exact `datadog-ci` version such as `v5.6.0` or `5.6.0`. +By default, this action installs the exact `datadog-ci` release pinned by the action version you use. To receive `datadog-ci` updates, update `datadog/junit-upload-github-action` to a newer release or use +the `datadog-ci-version` configuration to specify the version or range. + +This action passes the workflow `github.token` to the install step by default. That is primarily useful when `datadog-ci-version` uses a floating release selector such as `v5`, because GitHub release resolution can then be authenticated. + +## Maintainer release flow + +See [RELEASE.md](RELEASE.md) for the local `gh`-based process used to bump `datadog-ci` and release this action. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..ac922e5 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,44 @@ +# Release Process + +This repository ships a composite GitHub Action. Releases are Git tags: immutable semver tags such as `v3.0.1`, plus a moving major tag such as `v3`. + +## Bump datadog-ci + +Run the bump helper from a clean working tree: + +```bash +scripts/create-datadog-ci-bump-pr.sh +``` + +The script uses `gh` to check the latest `DataDog/datadog-ci` release. If that release is newer than the default in `action.yaml`, it creates a branch, updates `action.yaml` and `README.md`, pushes the branch, creates the `datadog-ci-version-bump` label if needed, and opens a PR with that label. + +To test a specific version instead of the latest release: + +```bash +scripts/create-datadog-ci-bump-pr.sh v5.14.0 +``` + +Review and merge the PR normally. + +## Release the action + +After a `datadog-ci-version-bump` PR is merged, run: + +```bash +scripts/release-datadog-ci-bump.sh +``` + +The script fetches `main` and tags, finds the latest merged PR with the `datadog-ci-version-bump` label that is on `main` but not included in the latest immutable action tag, and releases that merge commit. A release creates the next patch tag, updates the moving major tag, and creates a GitHub Release. + +Preview the release without creating tags or a GitHub Release: + +```bash +scripts/release-datadog-ci-bump.sh --dry-run +``` + +If multiple bump PRs were merged without releases, the script warns and releases only the latest one. To intentionally release an older merge commit separately, stop and pass a specific PR number or commit SHA before releasing the latest one: + +```bash +scripts/release-datadog-ci-bump.sh --pr 123 +scripts/release-datadog-ci-bump.sh --sha abc1234 +``` diff --git a/action.yaml b/action.yaml index 1e11fae..c02fe82 100644 --- a/action.yaml +++ b/action.yaml @@ -38,8 +38,8 @@ inputs: description: Set to "true" to enable forwarding content from XML reports as logs. datadog-ci-version: required: false - description: Version of datadog-ci to install. Use a major version like `v5` to get the latest release within that major version, or a specific tag like `v5.6.0` to pin. Legacy npm semver syntax (^, ~, >=, latest) is still supported but deprecated. - default: "v5" + description: Version of datadog-ci to install. Defaults to the pinned datadog-ci release shipped with this action version. Use a major version like `v5` to get the latest release within that major version, or a specific tag like `v5.6.0` to pin. Legacy npm semver syntax (^, ~, >=, latest) is still supported but deprecated. + default: "v5.14.0" github-token: required: false default: "" diff --git a/scripts/bump-datadog-ci-version.sh b/scripts/bump-datadog-ci-version.sh new file mode 100755 index 0000000..ddceb26 --- /dev/null +++ b/scripts/bump-datadog-ci-version.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 vX.Y.Z" >&2 + exit 1 +fi + +version="$1" + +if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Expected an exact datadog-ci release tag like v5.13.1, got '$version'" >&2 + exit 1 +fi + +ruby - "$version" <<'RUBY' +version = ARGV.fetch(0) + +action_path = 'action.yaml' +action = File.read(action_path) +action_pattern = /(^ datadog-ci-version:\n(?:(?!^ [A-Za-z0-9_-]+:).*\n)*?^ default: )"v\d+\.\d+\.\d+"/ +abort "Unable to find datadog-ci-version default in #{action_path}" unless action.match?(action_pattern) +File.write(action_path, action.sub(action_pattern) { "#{Regexp.last_match(1)}\"#{version}\"" }) + +readme_path = 'README.md' +readme = File.read(readme_path) +readme_pattern = /^(\| `datadog-ci-version` \|.*\| False\s+\| `)v\d+\.\d+\.\d+(`\s+\|)$/ +abort "Unable to find datadog-ci-version default in #{readme_path}" unless readme.match?(readme_pattern) +File.write(readme_path, readme.sub(readme_pattern) { "#{Regexp.last_match(1)}#{version}#{Regexp.last_match(2)}" }) +RUBY diff --git a/scripts/create-datadog-ci-bump-pr.sh b/scripts/create-datadog-ci-bump-pr.sh new file mode 100755 index 0000000..5746694 --- /dev/null +++ b/scripts/create-datadog-ci-bump-pr.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/create-datadog-ci-bump-pr.sh [vX.Y.Z] + +Checks the latest DataDog/datadog-ci release with gh, then opens a PR that bumps +the default datadog-ci-version when the release is newer than the current default. + +Arguments: + vX.Y.Z Optional exact datadog-ci release tag to use instead of releases/latest. + +Environment: + BASE_BRANCH Base branch for the PR. Defaults to main. + BRANCH_NAME Branch to create. Defaults to datadog-ci-bump/. + BUMP_LABEL Label applied to the PR. Defaults to datadog-ci-version-bump. + REMOTE Git remote to push to. Defaults to origin. + REPO GitHub repo for gh commands. Defaults to gh repo view's repo. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ $# -gt 1 ]]; then + usage >&2 + exit 1 +fi + +for command in gh git ruby; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Missing required command: $command" >&2 + exit 1 + fi +done + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Working tree must be clean before creating a bump PR." >&2 + exit 1 +fi + +gh auth status >/dev/null + +base_branch="${BASE_BRANCH:-main}" +bump_label="${BUMP_LABEL:-datadog-ci-version-bump}" +remote="${REMOTE:-origin}" +repo="${REPO:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" +requested_version="${1:-}" + +current_version=$(ruby -ryaml -e 'puts YAML.load_file("action.yaml").fetch("inputs").fetch("datadog-ci-version").fetch("default")') +if [[ -n "$requested_version" ]]; then + latest_version="$requested_version" +else + latest_version=$(gh api repos/DataDog/datadog-ci/releases/latest --jq '.tag_name') +fi + +if [[ ! "$latest_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Expected an exact datadog-ci release tag like v5.13.1, got '$latest_version'" >&2 + exit 1 +fi + +version_gt() { + local left="${1#v}" + local right="${2#v}" + local left_major left_minor left_patch right_major right_minor right_patch + + IFS=. read -r left_major left_minor left_patch <<< "$left" + IFS=. read -r right_major right_minor right_patch <<< "$right" + + (( left_major > right_major )) && return 0 + (( left_major < right_major )) && return 1 + (( left_minor > right_minor )) && return 0 + (( left_minor < right_minor )) && return 1 + (( left_patch > right_patch )) +} + +if ! version_gt "$latest_version" "$current_version"; then + echo "No datadog-ci bump needed. Current default is $current_version; latest is $latest_version." + exit 0 +fi + +branch_name="${BRANCH_NAME:-datadog-ci-bump/${latest_version#v}}" + +existing_pr_url=$(gh pr list \ + --repo "$repo" \ + --head "$branch_name" \ + --state open \ + --json url \ + --jq '.[0].url // ""') +if [[ -n "$existing_pr_url" ]]; then + echo "An open bump PR already exists: $existing_pr_url" + exit 0 +fi + +if git show-ref --verify --quiet "refs/heads/$branch_name"; then + echo "Local branch '$branch_name' already exists. Delete it or set BRANCH_NAME to another value." >&2 + exit 1 +fi + +git fetch "$remote" "$base_branch" +git checkout -b "$branch_name" FETCH_HEAD + +scripts/bump-datadog-ci-version.sh "$latest_version" + +git add action.yaml README.md +git commit -m "Bump datadog-ci to $latest_version" +git push -u "$remote" "$branch_name" + +gh label create "$bump_label" \ + --repo "$repo" \ + --description "Triggers a junit-upload-github-action release after merge" \ + --color "1D76DB" \ + --force + +body_file=$(mktemp) +trap 'rm -f "$body_file"' EXIT +cat > "$body_file" <&2 + exit 1 + fi + requested_pr="$2" + shift 2 + ;; + --sha) + if [[ -n "$requested_pr" || -n "$requested_sha" || -z "${2:-}" ]]; then + usage >&2 + exit 1 + fi + requested_sha="$2" + shift 2 + ;; + *) + usage >&2 + exit 1 + ;; + esac +done + +if [[ -n "$requested_pr" && ! "$requested_pr" =~ ^[0-9]+$ ]]; then + echo "--pr expects a numeric pull request number, got '$requested_pr'" >&2 + exit 1 +fi + +for command in gh git ruby; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Missing required command: $command" >&2 + exit 1 + fi +done + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Working tree must be clean before creating a release." >&2 + exit 1 +fi + +gh auth status >/dev/null + +base_branch="${BASE_BRANCH:-main}" +bump_label="${BUMP_LABEL:-datadog-ci-version-bump}" +remote="${REMOTE:-origin}" +repo="${REPO:-$(gh repo view --json nameWithOwner --jq '.nameWithOwner')}" +pr_limit="${PR_LIMIT:-200}" + +git fetch --tags "$remote" +git fetch "$remote" "$base_branch" +base_ref="refs/remotes/$remote/$base_branch" + +latest_tag=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1) +if [[ -z "$latest_tag" ]]; then + echo "No existing immutable action release tag found." >&2 + exit 1 +fi + +if ! git merge-base --is-ancestor "$latest_tag" "$base_ref"; then + echo "Latest immutable action tag $latest_tag is not in $remote/$base_branch history." >&2 + exit 1 +fi + +release_pr_number="" +release_pr_url="" +release_pr_merged_at="" +release_sha="" +unreleased_count=0 + +validate_release_sha() { + local sha="$1" + + if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then + echo "Commit '$sha' was not found locally." >&2 + return 1 + fi + + if ! git merge-base --is-ancestor "$sha" "$base_ref"; then + echo "Commit '$sha' is not in $remote/$base_branch history." >&2 + return 1 + fi + + if git merge-base --is-ancestor "$sha" "$latest_tag"; then + echo "Commit '$sha' is already included in latest action release $latest_tag." >&2 + return 1 + fi + + if ! git merge-base --is-ancestor "$latest_tag" "$sha"; then + echo "Commit '$sha' is not after latest action release $latest_tag." >&2 + return 1 + fi +} + +if [[ -n "$requested_sha" ]]; then + release_sha=$(git rev-parse "$requested_sha") + validate_release_sha "$release_sha" +elif [[ -n "$requested_pr" ]]; then + pr_data=$(gh pr view "$requested_pr" \ + --repo "$repo" \ + --json number,url,mergedAt,mergeCommit,baseRefName,labels,state \ + --jq ' + if .state != "MERGED" then + error("PR #\(.number) is not merged") + elif .baseRefName != "'"$base_branch"'" then + error("PR #\(.number) targets \(.baseRefName), not '"$base_branch"'") + elif ([.labels[].name] | index("'"$bump_label"'") | not) then + error("PR #\(.number) does not have label '"$bump_label"'") + else + [.number, .url, .mergedAt, .mergeCommit.oid] | @tsv + end + ') + IFS=$'\t' read -r release_pr_number release_pr_url release_pr_merged_at release_sha <<< "$pr_data" + validate_release_sha "$release_sha" +else + while IFS=$'\t' read -r pr_number pr_url merged_at merge_sha; do + [[ -z "$pr_number" ]] && continue + [[ -z "$merge_sha" ]] && continue + + if ! validate_release_sha "$merge_sha" 2>/dev/null; then + continue + fi + + unreleased_count=$((unreleased_count + 1)) + release_pr_number="$pr_number" + release_pr_url="$pr_url" + release_pr_merged_at="$merged_at" + release_sha="$merge_sha" + done < <( + gh pr list \ + --repo "$repo" \ + --state merged \ + --base "$base_branch" \ + --label "$bump_label" \ + --limit "$pr_limit" \ + --json number,url,mergedAt,mergeCommit \ + --jq 'sort_by(.mergedAt) | .[] | [.number, .url, .mergedAt, .mergeCommit.oid] | @tsv' + ) + + if (( unreleased_count > 1 )); then + echo "Warning: found $unreleased_count unreleased merged PRs with label '$bump_label'." >&2 + echo "This script will release only the latest one, #$release_pr_number." >&2 + echo "To release an older merge commit separately, stop and rerun with --pr NUMBER or --sha COMMIT before releasing the latest one." >&2 + fi +fi + +if [[ -z "$release_sha" ]]; then + echo "No unreleased merged PR with label '$bump_label' found on $remote/$base_branch." + exit 0 +fi + +if [[ -n "$release_pr_number" ]]; then + release_source="#$release_pr_number: $release_pr_url" +else + release_source="$release_sha" +fi + +version="${latest_tag#v}" +IFS=. read -r major minor patch <<< "$version" +next_tag="v${major}.${minor}.$((patch + 1))" +major_tag="v${major}" + +if git rev-parse --verify --quiet "refs/tags/$next_tag" >/dev/null; then + echo "Next release tag $next_tag already exists locally." >&2 + exit 1 +fi + +if gh release view "$next_tag" --repo "$repo" >/dev/null 2>&1; then + echo "GitHub Release $next_tag already exists." >&2 + exit 1 +fi + +datadog_ci_version=$(git show "$release_sha:action.yaml" | ruby -ryaml -e 'puts YAML.load($stdin.read).fetch("inputs").fetch("datadog-ci-version").fetch("default")') +if [[ ! "$datadog_ci_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Expected datadog-ci-version default to be an exact release tag at $release_sha, got '$datadog_ci_version'" >&2 + exit 1 +fi + +notes_file=$(mktemp) +trap 'rm -f "$notes_file"' EXIT +cat > "$notes_file" <> "$notes_file" +fi + +echo "Latest action release tag: $latest_tag" +echo "Next action release tag: $next_tag" +echo "Moving major tag: $major_tag" +echo "Release commit: $release_sha" +if [[ -n "$release_pr_number" ]]; then + echo "Release PR: #$release_pr_number" +fi +echo "datadog-ci version: $datadog_ci_version" + +if [[ "$dry_run" == "true" ]]; then + echo "Dry run only. No tags or GitHub Release were created." + exit 0 +fi + +git tag "$next_tag" "$release_sha" +git tag -f "$major_tag" "$release_sha" +git push "$remote" "refs/tags/$next_tag" +git push --force "$remote" "refs/tags/$major_tag" + +gh release create "$next_tag" \ + --repo "$repo" \ + --target "$release_sha" \ + --title "$next_tag" \ + --notes-file "$notes_file"