diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dde8debe7..78bfe6018 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,3 +342,14 @@ jobs: skopeo copy --all oci-archive:fasttrackml-oci.tar:$tag docker://ghcr.io/${{ steps.repo.outputs.name }}:$tag echo "::endgroup::" done + + release: + name: Release + needs: all-required-checks-done + if: ${{ !github.event.repository.fork && github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') }} + permissions: + contents: write + pages: write + id-token: write + secrets: inherit + uses: ./.github/workflows/release.yml diff --git a/.github/workflows/create.yml b/.github/workflows/create.yml new file mode 100644 index 000000000..0186b0c37 --- /dev/null +++ b/.github/workflows/create.yml @@ -0,0 +1,96 @@ +name: Create release branch or tag + +on: + workflow_dispatch: + inputs: + type: + description: "What to create (branch or tag)" + required: true + type: choice + options: + - "branch" + - "tag" + version: + description: "Version - major and minor for a branch (e.g. 0.3), semver for a tag (e.g. 0.3.1 or 0.3.1-rc.1)" + required: true + +jobs: + create-release: + name: Create release branch from main + if: github.event.inputs.type == 'branch' + environment: create-release + runs-on: ubuntu-latest + steps: + - name: Check version + id: version + uses: actions/github-script@v6 + with: + script: | + const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/; + const version = context.payload.inputs.version; + const match = version.match(semver); + if (match === null) { + core.setFailed('Invalid version format. Expected "MAJOR.MINOR".'); + } else { + core.setOutput('branch', `release/${version}`); + } + + - name: Generate an app token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout main branch + uses: actions/checkout@v4 + with: + ref: main + token: ${{ steps.app-token.outputs.token }} + + - name: Push + run: | + branch=${{ steps.version.outputs.branch }} + git checkout -b $branch + git push origin $branch + + create-tag: + name: Create release tag from release branch + if: github.event.inputs.type == 'tag' + environment: create-release + runs-on: ubuntu-latest + steps: + # Regex comes from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + - name: Check version + id: version + uses: actions/github-script@v6 + with: + script: | + const semver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + const version = context.payload.inputs.version; + const match = version.match(semver); + if (match === null) { + core.setFailed('Invalid version format. Expected semver compliant version.'); + } else { + core.setOutput('tag', `v${version}`); + core.setOutput('branch', `release/${match[1]}.${match[2]}`); + } + + - name: Generate an app token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout release branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.version.outputs.branch }} + token: ${{ steps.app-token.outputs.token }} + + - name: Push + run: | + tag=${{ steps.version.outputs.tag }} + git tag $tag + git push origin $tag diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac898f047..1db79309a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,96 +1,55 @@ name: Release on: - workflow_run: - types: [completed] - workflows: [CI] - branches: - - main - - v* + workflow_call: permissions: contents: read jobs: - validate: - name: Validate ref - if: github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' && !github.event.repository.fork - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # The given ref should belong to the main branch. - # If it's main, it shouldn't be more than 2 commits away (in case another push happened in the meantime). - # If it starts with 'v', it should be a tag and belong to the main branch. - # Anything else is invalid. - - name: Validate ref - run: | - ref='${{ github.event.workflow_run.head_branch }}' - sha='${{ github.event.workflow_run.head_sha }}' - case $ref in - main) - [ $(git branch --contains=$sha main | wc -l) -eq 1 ] && - [ $(git rev-list --count $sha..main) -le 2 ] - ;; - v?*) - [[ $ref =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && - [ $(git tag --points-at $sha | grep -E "^$ref\$" | wc -l) -eq 1 ] && - [ $(git branch --contains=$sha main | wc -l) -eq 1 ] - ;; - *) - false - ;; - esac - if [ $? -ne 0 ]; then - echo "::error ::Invalid ref $ref $sha" - exit 1 - fi - pypi-publish: name: upload release to PyPI - needs: validate - if: github.event.workflow_run.head_branch != 'main' + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: release permissions: id-token: write steps: - name: Download artifact - run: gh run download ${{ github.event.workflow_run.id }} --repo ${{ github.event.workflow_run.repository.full_name }} --name fasttrackml-wheels --dir wheelhouse - env: - GH_TOKEN: ${{ github.token }} + uses: actions/download-artifact@v3 + with: + name: fasttrackml-wheels + path: wheelhouse - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - packages-dir: wheelhouse/ + packages-dir: wheelhouse github-release: name: Publish GitHub release - needs: validate - if: github.event.workflow_run.head_branch != 'main' + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: write steps: - name: Download artifact - run: gh run download ${{ github.event.workflow_run.id }} --repo ${{ github.event.workflow_run.repository.full_name }} --name fasttrackml-archives --dir dist - env: - GH_TOKEN: ${{ github.token }} + uses: actions/download-artifact@v3 + with: + name: fasttrackml-archives + path: dist - name: Create release uses: softprops/action-gh-release@v1 with: generate_release_notes: true files: dist/* - tag_name: ${{ github.event.workflow_run.head_branch }} + prerelease: ${{ contains(github.ref, '-') }} update-website: name: Update website needs: github-release + if: ${{ !contains(github.ref, '-') }} permissions: contents: read pages: write @@ -99,35 +58,43 @@ jobs: docker-release: name: Publish container image to DockerHub - needs: validate + if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: release steps: + # We need to checkout the repo in order to determine the latest tag. - name: Checkout + if: startsWith(github.ref, 'refs/tags/v') uses: actions/checkout@v4 with: fetch-depth: 0 + # The main branch is tagged as "main" and "edge". + # Tags are named after the version, e.g. "v0.1.0" -> "0.1.0". + # The latest non-prerelease version is also tagged as "latest". + # This is achieved by sorting the tags by version number, then filtering + # out prereleases and taking the last tag. - name: Compute tags id: tags run: | - ref='${{ github.event.workflow_run.head_branch }}' + ref='${{ github.ref }}' case $ref in - main) + refs/heads/main) tags=("main" "edge") ;; - v*) - tags=("${ref#v}") - if [ $(git describe --tags --abbrev=0) == $ref ]; then + refs/tags/v*) + tags=("${ref#refs/tags/v}") + if [ "$(git -c 'versionsort.suffix=-' for-each-ref --sort=version:refname --format='%(refname)' 'refs/tags/v*' | grep -v -- - | tail -n1)" == "$ref" ]; then tags+=("latest") fi esac + echo "ref=${ref#refs/*/}" >> $GITHUB_OUTPUT echo "tags=${tags[@]}" >> $GITHUB_OUTPUT - name: Download artifact - run: gh run download ${{ github.event.workflow_run.id }} --name fasttrackml-oci-image - env: - GH_TOKEN: ${{ github.token }} + uses: actions/download-artifact@v3 + with: + name: fasttrackml-oci-image - name: Login to Docker Hub uses: docker/login-action@v3 @@ -140,6 +107,6 @@ jobs: for tag in ${{ steps.tags.outputs.tags }} do echo "::group::Pushing image to ${{ vars.DOCKER_REPO }}:$tag" - skopeo copy --all oci-archive:fasttrackml-oci.tar:${{ github.event.workflow_run.head_branch }} docker://${{ vars.DOCKER_REPO }}:$tag + skopeo copy --all oci-archive:fasttrackml-oci.tar:${{ steps.tags.outputs.ref }} docker://${{ vars.DOCKER_REPO }}:$tag echo "::endgroup::" done diff --git a/docs/maintainer.md b/docs/maintainer.md new file mode 100644 index 000000000..e4aedae7d --- /dev/null +++ b/docs/maintainer.md @@ -0,0 +1,154 @@ +# Maintainer guide + +## Release workflow + +* All development goes into the `main` branch via pull requests that need to be + approved and checks that need to pass. +* When we want to cut a new major or minor release, we can create a new release + branch named `release/MAJOR.MINOR` (e.g. `release/0.4`) by manually running + the `Create release branch or tag` workflow. +* We can then run the workflow again to create a tag, specifying the complete + semantic version (e.g. `0.4.0` or `0.4.0-beta.1`). +* Development continues on the `main` branch. +* Fixes can be backported to the release branch via pull requests that need to + be approved and checks that need to pass. +* A new patch release can then be made by calling the + `Create release branch or tag` workflow again. + +## How is it enforced + +* A GitHub app needs to exist and be installed on the repo with the + `contents:write` permissions. Its ID and private key need to be stored as + secrets under the `create-release` environment. This environment needs to be + limited to the `main` branch only. +* An environment named `release` needs to exist and be limited to the `main` + branch and the `v*` tags only. It needs to contain the Docker Hub credentials + as secrets and be registered on PyPI as a trusted provider for our Python + package. +* The following rulesets must exist on the repo to enforce this workflow and + guarantee that a single maintainer cannot make changes to the codebase that + have not been reviewed by their peers. They replace any other branch + protection rules that may have existed before. + ```json + [ + { + "name": "Allow bot to create release branches", + "target": "branch", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/release/**/*" + ] + } + }, + "rules": [ + { + "type": "creation" + } + ], + "bypass_actors": [ + { + "actor_id": 0, + "actor_type": "Integration", + "bypass_mode": "always" + } + ] + }, + { + "name": "Allow bot to create tags", + "target": "tag", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "~ALL" + ] + } + }, + "rules": [ + { + "type": "creation" + } + ], + "bypass_actors": [ + { + "actor_id": 0, + "actor_type": "Integration", + "bypass_mode": "always" + } + ] + }, + { + "name": "Protect all tags", + "target": "tag", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "~ALL" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "update" + } + ], + "bypass_actors": [] + }, + { + "name": "Protect main and release branches", + "target": "branch", + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "refs/heads/main", + "refs/heads/release/**/*" + ] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "type": "pull_request", + "parameters": { + "require_code_owner_review": false, + "require_last_push_approval": true, + "dismiss_stale_reviews_on_push": true, + "required_approving_review_count": 1, + "required_review_thread_resolution": false + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "All required checks succeeded", + "integration_id": 0 + } + ], + "strict_required_status_checks_policy": true + } + } + ], + "bypass_actors": [] + } + ] + ``` \ No newline at end of file