diff --git a/.github/workflows/chart-pr.yml b/.github/workflows/chart-pr.yml index 3fdbbbb..1f786d8 100644 --- a/.github/workflows/chart-pr.yml +++ b/.github/workflows/chart-pr.yml @@ -14,6 +14,10 @@ on: description: Space-separated paths that never count as a chart change (default /README.md) type: string default: "" + helm-docs-version: + description: Version of the jnorwood/helm-docs image to regenerate the charts' README.md with, e.g. v1.14.2 (default none, no regeneration) + type: string + default: "" jobs: semver-label: @@ -92,17 +96,47 @@ jobs: EXCLUDE: ${{ inputs.exclude }} run: ../chart-workflows/scripts/bump-chart-versions.sh + # after the bump, so that the documentation shows the new versions. Only the charts the pull + # request changes: documentation another chart left stale would change that chart without a new + # version, which chart-release.yml refuses. + - name: Regenerate the chart documentation + id: docs + if: inputs.helm-docs-version != '' && steps.bump.outputs.changed != '' + working-directory: pr + env: + HELM_DOCS_VERSION: ${{ inputs.helm-docs-version }} + CHARTS_DIR: ${{ inputs.charts-dir }} + CHANGED: ${{ steps.bump.outputs.changed }} + run: | + for chart in $CHANGED; do + docker run --rm --volume "$PWD:/helm-docs" --user "$(id -u)" "jnorwood/helm-docs:$HELM_DOCS_VERSION" \ + --chart-search-root "$CHARTS_DIR/$chart" + done + if git diff --quiet; then + echo "The chart documentation is up to date" + echo "committed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git add --update + bot_name="github-actions[bot]" + bot_email="41898282+github-actions[bot]@users.noreply.github.com" + GIT_AUTHOR_NAME="$bot_name" GIT_AUTHOR_EMAIL="$bot_email" \ + GIT_COMMITTER_NAME="$bot_name" GIT_COMMITTER_EMAIL="$bot_email" \ + git commit --quiet -m "Update helm documentation" + git show --stat --format='%s' HEAD + echo "committed=true" >> "$GITHUB_OUTPUT" + # the workflow token cannot push to a fork - name: Versions to set in a pull request from a fork - if: steps.bump.outputs.committed == 'true' && github.event.pull_request.head.repo.full_name != github.repository + if: (steps.bump.outputs.committed == 'true' || steps.docs.outputs.committed == 'true') && github.event.pull_request.head.repo.full_name != github.repository working-directory: pr run: | - echo "::error::This pull request comes from a fork, so the chart versions cannot be updated automatically. Set them in the pull request:" - git show --format= HEAD + echo "::error::This pull request comes from a fork, so the chart versions and documentation cannot be updated automatically. Set them in the pull request:" + git diff "origin/$GITHUB_HEAD_REF" HEAD exit 1 - name: Push the versions - if: steps.bump.outputs.committed == 'true' && github.event.pull_request.head.repo.full_name == github.repository + if: (steps.bump.outputs.committed == 'true' || steps.docs.outputs.committed == 'true') && github.event.pull_request.head.repo.full_name == github.repository working-directory: pr env: HEAD_REF: ${{ github.head_ref }} diff --git a/.github/workflows/semver-version.yml b/.github/workflows/semver-version.yml new file mode 100644 index 0000000..4e92499 --- /dev/null +++ b/.github/workflows/semver-version.yml @@ -0,0 +1,113 @@ +# Reusable workflow that works out the version a pull request releases from its semver label (major, +# minor or patch): on a pull request, the pre-release X.Y.Z-PRE-, asking for the label when +# it is missing; on a push to the main branch, X.Y.Z from the label of the merged pull request. See +# README.md for how to call it. +name: Semver version + +on: + workflow_call: + inputs: + tag-prefix: + description: Text the release tags start with (default v); empty for tags like 1.2.3 + type: string + default: v + outputs: + version: + description: X.Y.Z on a push, X.Y.Z-PRE- on a pull request + value: ${{ jobs.version.outputs.version }} + bump: + description: major, minor or patch, from the label + value: ${{ jobs.version.outputs.bump }} + +jobs: + version: + name: Semver version + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + outputs: + version: ${{ steps.version.outputs.version }} + bump: ${{ steps.label.outputs.bump }} + steps: + # all the tags, to find the latest release + - name: Check out the repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + path: repo + persist-credentials: false + + # the script from the same commit as this workflow, not from the caller's repository + - name: Locate this workflow + env: + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + if [ -z "$WORKFLOW_REPOSITORY" ] || [ -z "$WORKFLOW_SHA" ]; then + echo "::error::job.workflow_repository and job.workflow_sha are empty: this workflow has to run as a reusable workflow on github.com" + exit 1 + fi + echo "Using $WORKFLOW_REPOSITORY@$WORKFLOW_SHA" + + - name: Check out the version script + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: semver-workflows + persist-credentials: false + + # on a push, the labels of the pull request the pushed commit was merged from + - name: Read the semver label + id: label + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + SHA: ${{ github.sha }} + run: | + if [ "$EVENT" = pull_request ]; then + labels="$PR_LABELS" + else + pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/pulls" \ + --jq '[.[] | select(.merged_at != null)][0] // empty | {number, labels: [.labels[].name]}') + if [ -z "$pr" ]; then + echo "::error::No merged pull request contains $SHA, so there is no semver label to release it with" + exit 1 + fi + echo "Releasing pull request #$(jq -r .number <<< "$pr")" + labels=$(jq -c .labels <<< "$pr") + fi + bumps=$(jq -r '[.[] | select(. == "major" or . == "minor" or . == "patch")] | join(" ")' <<< "$labels") + if [ "$(wc -w <<< "$bumps")" -ne 1 ]; then + echo "::error::The pull request needs exactly one of the labels major, minor or patch (found: ${bumps:-none})" + exit 1 + fi + echo "bump=$bumps" >> "$GITHUB_OUTPUT" + + - name: Ask for the label + if: failure() && steps.label.outcome == 'failure' && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" \ + --body "Please apply exactly one of the following labels to the PR: 'patch', 'minor', 'major'." \ + || echo "::warning::Could not comment on the pull request" + + - name: Work out the version + id: version + working-directory: repo + env: + BUMP: ${{ steps.label.outputs.bump }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + EVENT: ${{ github.event_name }} + PR: ${{ github.event.pull_request.number }} + run: | + version=$(../semver-workflows/scripts/next-version.sh) + if [ "$EVENT" = pull_request ]; then + version="$version-PRE-$PR" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Version: $version" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cd74199..4593b3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,3 +60,13 @@ jobs: permissions: contents: write pull-requests: write + + # Exercises semver-version.yml before it is released: the version this pull request would release, + # as a pre-release. Only once semver-label passed, so a missing label is reported once. + semver-version: + if: github.event_name == 'pull_request' + needs: semver-label + uses: ./.github/workflows/semver-version.yml + permissions: + contents: read + pull-requests: write diff --git a/README.md b/README.md index 4c1159c..73555b7 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,13 @@ Reusable GitHub Actions workflows shared by FIWARE repositories, so that a fix is made in one place. -The repository lives in the SEAMWARE organization until it moves to FIWARE; callers then change the owner in `uses:`. - | Workflow | What it does | Typical trigger | |---|---|---| | [`chart-lint.yml`](#chart-lint-chart-lintyml) | Lints the Helm charts and validates what they render | Pull requests | | [`chart-pr.yml`](#chart-version-chart-pryml) | Requires a semver label and bumps the versions of the changed charts | Pull requests | | [`chart-release.yml`](#chart-release-chart-releaseyml) | Publishes the charts, failing when a changed chart has no new version | Push to the main branch | | [`chart-dependencies.yml`](#chart-dependencies-chart-dependenciesyml) | Opens pull requests for newer chart dependencies | Schedule | +| [`semver-version.yml`](#semver-version-semver-versionyml) | Works out the version to release from the semver label of the pull request | Pull requests and push to the main branch | | [`stale.yml`](#stale-issues-staleyml) | Marks and closes issues and pull requests without activity | Schedule | Together, the chart workflows cover the life of a chart: a pull request is linted and gets its new version, and merging it publishes that version. @@ -21,7 +20,7 @@ Lints every chart under `charts/` (the directories with a `Chart.yaml`) with `he ```yaml jobs: chart-lint: - uses: SEAMWARE/github-workflows/.github/workflows/chart-lint.yml@v1 + uses: FIWARE/github-workflows/.github/workflows/chart-lint.yml@v1 ``` | Input | Default | Description | @@ -59,7 +58,7 @@ jobs: chart-version: needs: [lint] # whatever has to pass first - uses: SEAMWARE/github-workflows/.github/workflows/chart-pr.yml@v1 + uses: FIWARE/github-workflows/.github/workflows/chart-pr.yml@v1 permissions: contents: write # push the new versions to the pull request branch pull-requests: write # comment when the label is missing @@ -71,6 +70,7 @@ Inputs, both optional: |---|---|---| | `charts-dir` | `charts` | Directory that holds one directory per chart | | `exclude` | `/README.md` | Space-separated paths that never count as a chart change | +| `helm-docs-version` | (none) | Version of the [`jnorwood/helm-docs`](https://github.com/norwoodj/helm-docs) image, e.g. `v1.14.2`. When set, the `README.md` of every chart the pull request changes is regenerated after the bump and pushed with it. Other charts are left alone: regenerating a stale one would change it without a new version | Pushes made with the workflow token do not trigger workflows, so the checks of the pull request keep running on the commit before the version bump. @@ -80,7 +80,7 @@ Publishes the charts when the main branch changes: 1. **checks the versions first**: a chart that the push changed without changing its version fails the workflow, naming it, before anything is published. Such a change would otherwise never be released, since the version is already published; 2. publishes every chart version that is not released yet as a GitHub release, and updates the Helm repository index on GitHub Pages, with [chart-releaser](https://github.com/helm/chart-releaser-action); -3. optionally, pushes them to an OCI registry, each version once. +3. optionally, pushes them to an OCI registry, each version once, retrying a push that fails. The repositories the charts' dependencies come from are added from their `Chart.yaml`. @@ -91,7 +91,7 @@ on: jobs: chart-release: - uses: SEAMWARE/github-workflows/.github/workflows/chart-release.yml@v1 + uses: FIWARE/github-workflows/.github/workflows/chart-release.yml@v1 permissions: contents: write with: @@ -121,7 +121,7 @@ on: jobs: chart-dependencies: - uses: SEAMWARE/github-workflows/.github/workflows/chart-dependencies.yml@v1 + uses: FIWARE/github-workflows/.github/workflows/chart-dependencies.yml@v1 permissions: contents: write pull-requests: write @@ -137,6 +137,45 @@ jobs: The chart version is bumped in the pull request itself because pull requests opened with the workflow token do not run their checks, `chart-pr.yml` included. To have the checks run, pass a GitHub App or personal token as the `token` secret. +## Semver version (`semver-version.yml`) + +For repositories that release an application, not charts, with the version driven by the semver label of the pull request. It works out the version and leaves the build and the release to the caller: + +- on a pull request, it requires exactly one of the labels `major`, `minor` or `patch`, commenting on the pull request when it is missing, and returns `X.Y.Z-PRE-`; +- on a push to the main branch, it reads the label of the pull request the pushed commit was merged from, and returns `X.Y.Z`. A commit that no merged pull request contains, or one merged without a label, fails. + +`X.Y.Z` is the highest release tag bumped by the label. Release tags are `X.Y.Z`; other tags, such as pre-releases (`1.2.3-PRE-4`), are ignored. + +```yaml +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + generate-version: + uses: FIWARE/github-workflows/.github/workflows/semver-version.yml@v1 + permissions: + contents: read + pull-requests: write # comment when the label is missing + with: + tag-prefix: "" # tags like 1.2.3 + + build-and-push: + needs: generate-version + runs-on: ubuntu-latest + steps: + - run: echo "Building ${{ needs.generate-version.outputs.version }}" +``` + +| Input | Default | Description | +|---|---|---| +| `tag-prefix` | `v` | Text the release tags start with; empty for tags like `1.2.3` | + +| Output | Description | +|---|---| +| `version` | `X.Y.Z` on a push, `X.Y.Z-PRE-` on a pull request | +| `bump` | `major`, `minor` or `patch`, from the label | + ## Stale issues (`stale.yml`) Marks issues and pull requests without activity as stale, and closes them when they stay so, with [actions/stale](https://github.com/actions/stale). @@ -149,7 +188,7 @@ on: jobs: stale: - uses: SEAMWARE/github-workflows/.github/workflows/stale.yml@v1 + uses: FIWARE/github-workflows/.github/workflows/stale.yml@v1 permissions: issues: write pull-requests: write diff --git a/scripts/bump-chart-versions.sh b/scripts/bump-chart-versions.sh index e283851..1a35311 100755 --- a/scripts/bump-chart-versions.sh +++ b/scripts/bump-chart-versions.sh @@ -14,7 +14,8 @@ # CHARTS_DIR directory that holds one directory per chart (default: charts) # EXCLUDE space-separated paths that never count as a chart change # (default: $CHARTS_DIR/README.md) -# GITHUB_OUTPUT when set, receives committed=true|false and the bumped charts +# GITHUB_OUTPUT when set, receives committed=true|false, the bumped charts and the changed +# charts (changed=, also the ones whose version was already right) # # Requires git and yq (mikefarah, v4). @@ -53,8 +54,11 @@ while IFS= read -r file; do esac done < <(git diff --name-only "$base...HEAD") +changed=$(printf '%s\n' "${!charts[@]}" | sort | xargs) +output "changed=$changed" + bumped=() -for chart in $(printf '%s\n' "${!charts[@]}" | sort); do +for chart in $changed; do chart_file="$CHARTS_DIR/$chart/Chart.yaml" if [ ! -f "$chart_file" ]; then echo "$chart has no Chart.yaml on the branch, skipping" diff --git a/scripts/next-version.sh b/scripts/next-version.sh index 56ebee4..a7765c6 100755 --- a/scripts/next-version.sh +++ b/scripts/next-version.sh @@ -1,16 +1,24 @@ #!/usr/bin/env bash -# Prints the next release version (X.Y.Z, without the v) of the repository in the current -# directory: its highest vX.Y.Z tag bumped by BUMP, or 0.0.0 bumped when there is none. Tags that -# are not plain vX.Y.Z, such as the moving major tags (v1) or pre-releases, are ignored. +# Prints the next release version (X.Y.Z, without the prefix) of the repository in the current +# directory: its highest X.Y.Z tag bumped by BUMP, or 0.0.0 bumped when there is none. Tags +# that are not plain X.Y.Z, such as the moving major tags (v1) or pre-releases, are ignored. # # Environment: -# BUMP major, minor or patch (required) +# BUMP major, minor or patch (required) +# TAG_PREFIX text the release tags start with (default: v); empty for tags like 1.2.3 set -euo pipefail : "${BUMP:?BUMP must be major, minor or patch}" -latest=$(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sed 's/^v//' | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1 || true) +TAG_PREFIX="${TAG_PREFIX-v}" + +latest=$(git tag --list "$TAG_PREFIX*" | while IFS= read -r tag; do + version="${tag#"$TAG_PREFIX"}" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "$version" + fi +done | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1 || true) IFS=. read -r major minor patch <<< "${latest:-0.0.0}" case "$BUMP" in diff --git a/scripts/publish-oci.sh b/scripts/publish-oci.sh index 19dd48a..ba44bd8 100755 --- a/scripts/publish-oci.sh +++ b/scripts/publish-oci.sh @@ -8,6 +8,8 @@ # CHARTS space-separated chart names to push (default: every chart under CHARTS_DIR) # CHARTS_DIR directory that holds one directory per chart (default: charts) # HELM helm binary (default: helm) +# ATTEMPTS attempts for each push, as registries answer 502 now and then (default: 3) +# RETRY_DELAY seconds between the attempts (default: 10) # # Requires yq (mikefarah, v4). Run add-helm-repos.sh first for charts with dependencies. @@ -16,8 +18,23 @@ set -euo pipefail : "${REGISTRY:?REGISTRY must name the OCI registry and path, without oci://}" CHARTS_DIR="${CHARTS_DIR:-charts}" HELM="${HELM:-helm}" +ATTEMPTS="${ATTEMPTS:-3}" +RETRY_DELAY="${RETRY_DELAY:-10}" REGISTRY="${REGISTRY#oci://}" +# retry ...: runs the command up to ATTEMPTS times, until it succeeds +retry() { + local attempt=1 + until "$@"; do + if [ "$attempt" -ge "$ATTEMPTS" ]; then + return 1 + fi + echo "Attempt $attempt of $ATTEMPTS failed, retrying in ${RETRY_DELAY}s" >&2 + sleep "$RETRY_DELAY" + attempt=$((attempt + 1)) + done +} + if [ -z "${CHARTS:-}" ]; then CHARTS="" for chart_file in "$CHARTS_DIR"/*/Chart.yaml; do @@ -40,5 +57,5 @@ for chart in $CHARTS; do echo "Pushing $name $version to $REGISTRY" "$HELM" dependency build "$chart_dir" "$HELM" package "$chart_dir" --destination "$package_dir" - "$HELM" push "$package_dir/$name-$version.tgz" "oci://$REGISTRY" + retry "$HELM" push "$package_dir/$name-$version.tgz" "oci://$REGISTRY" done diff --git a/tests/bump-chart-versions.bats b/tests/bump-chart-versions.bats index 01dabb6..4d22c66 100644 --- a/tests/bump-chart-versions.bats +++ b/tests/bump-chart-versions.bats @@ -78,6 +78,7 @@ version() { [ "$(git rev-parse HEAD)" = "$head" ] [ "$(version a)" = 1.2.4 ] grep -qx "committed=false" "$GITHUB_OUTPUT" + grep -qx "changed=a" "$GITHUB_OUTPUT" } @test "a version set by hand to the expected one is kept" { diff --git a/tests/next-version.bats b/tests/next-version.bats index 68fe02c..e891be4 100644 --- a/tests/next-version.bats +++ b/tests/next-version.bats @@ -58,3 +58,19 @@ setup() { BUMP=huge run "$SCRIPT" [ "$status" -ne 0 ] } + +@test "TAG_PREFIX selects the release tags" { + git tag v9.0.0 + git tag release-1.2.0 + TAG_PREFIX=release- BUMP=patch run "$SCRIPT" + [ "$output" = 1.2.1 ] +} + +@test "an empty TAG_PREFIX reads tags without a prefix and ignores pre-releases" { + git tag v9.0.0 + git tag 1.8.4 + git tag 1.8.5-PRE-34 + git tag 1.10.0 + TAG_PREFIX= BUMP=patch run "$SCRIPT" + [ "$output" = 1.10.1 ] +} diff --git a/tests/publish-oci.bats b/tests/publish-oci.bats index 8532ef3..75211da 100644 --- a/tests/publish-oci.bats +++ b/tests/publish-oci.bats @@ -32,3 +32,21 @@ EOF [ "$status" -eq 0 ] ! grep -q "charts/b" "$BATS_TEST_TMPDIR/helm.log" } + +@test "a failed push is retried" { + cat >> "$BATS_TEST_TMPDIR/helm.sh" <<'EOF2' +if [ "$1" = push ] && [ "$(grep -c '^push ' "$BATS_TEST_TMPDIR/helm.log")" -lt 2 ]; then + exit 1 +fi +EOF2 + RETRY_DELAY=0 run "$SCRIPT" + [ "$status" -eq 0 ] + [ "$(grep -c '^push .*/b-2.0.0.tgz' "$BATS_TEST_TMPDIR/helm.log")" -eq 2 ] +} + +@test "a push that keeps failing fails after ATTEMPTS attempts" { + echo 'if [ "$1" = push ]; then exit 1; fi' >> "$BATS_TEST_TMPDIR/helm.sh" + ATTEMPTS=2 RETRY_DELAY=0 run "$SCRIPT" + [ "$status" -ne 0 ] + [ "$(grep -c '^push ' "$BATS_TEST_TMPDIR/helm.log")" -eq 2 ] +}