diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..d6986d4 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,124 @@ +name: auto-release + +# Merging a release note is the release. +# +# A pull request that adds docs/release-notes/.md (2.34.1.4.md) +# is stating that the merge it belongs to is a release: the note is hand-written, one per +# version, and nobody writes one by accident. +# So merging it tags the merge commit and starts the ordinary release run. Nothing else about the +# release path changes — the tag is a real tag at a real commit, release.yml resolves the version +# from it exactly as it does for a hand-pushed tag, and the guard job there still proves the +# commit is on the default branch before anything is published. +# +# Triggered on the push to main rather than on `pull_request: closed`, for two reasons: a push +# to the default branch carries a full-permission token whatever the pull request's origin was +# (a fork pull request's token is read-only and could not push the tag), and it sees the merge +# identically whether it arrived as a merge commit, a squash or a rebase. + +on: + push: + branches: ['main'] + paths: + - 'docs/release-notes/**' + +concurrency: + group: auto-release-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + # contents: write pushes the tag. actions: write dispatches release.yml, and that dispatch is + # not a stylistic choice: a tag pushed with GITHUB_TOKEN deliberately does not trigger + # `on: push: tags`, so release.yml would sit there and never start. workflow_dispatch is + # documented as an exception which always creates a run, which is why release.yml carries a + # workflow_dispatch trigger alongside its tag trigger. + contents: write + actions: write + +jobs: + release: + name: tag and release the notes added here + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The whole history, so the diff below can reach the previous commit and so + # `git rev-parse refs/tags/...` can see tags that already exist. + fetch-depth: 0 + + - name: Tag every release note this push added, and start its release + env: + GH_TOKEN: ${{ github.token }} + BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + + # A brand-new branch reports an all-zero "before" and there is nothing to diff against. + case "${BEFORE}" in + 0000000000000000000000000000000000000000|'') + echo "no previous commit to diff against; nothing to do" + exit 0 + ;; + esac + + # --diff-filter=A: added, not modified. Editing an existing note is a correction to a + # release that already happened, and must not tag anything. + added="$(git diff --name-status --diff-filter=A "${BEFORE}" "${GITHUB_SHA}" \ + -- 'docs/release-notes/*.md' | cut -f2)" + + if [ -z "${added}" ]; then + echo "no release notes added in this push; nothing to do" + exit 0 + fi + + # An annotated tag needs a tagger, and a runner has no git identity configured — without + # this, `git tag -a` fails with "Committer identity unknown". + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + count=0 + while IFS= read -r file; do + [ -n "${file}" ] || continue + base="$(basename "${file}" .md)" + + # README.md documents the folder in several of these repositories. + if [ "${base}" = 'README' ]; then + continue + fi + + tag="v${base}" + + # Four-part versions only. release.yml's own version job also accepts three parts, + # but every release tag this repository has ever carried is four-part, and the + # three-part notes that exist are series overviews rather than releases — tagging one + # of those would publish something nobody asked for. A genuine three-part release can + # still be tagged by hand; only this automatic path is strict. + if ! printf '%s' "${tag}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::warning::${file} does not name a release this repository publishes (would be '${tag}'); skipping" + continue + fi + + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "${tag} already exists; skipping" + continue + fi + + echo "==> tagging ${GITHUB_SHA} as ${tag} for ${file}" + git tag -a "${tag}" "${GITHUB_SHA}" -m "Release ${tag}" + git push origin "${tag}" + + # Dispatched at the tag's own ref, so github.ref_name inside release.yml is the tag + # and its version/track resolution, release-notes lookup and changelog range all + # behave exactly as they do for a hand-pushed tag. + gh workflow run release.yml --ref "${tag}" + echo "==> dispatched release.yml at ${tag}" + + { + echo "- \`${tag}\` tagged from ${file} and released" + } >> "$GITHUB_STEP_SUMMARY" + count=$((count + 1)) + done <<< "${added}" + + if [ "${count}" -eq 0 ]; then + echo "nothing tagged" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d054fd..945e4c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,16 @@ name: build on: workflow_call: inputs: + verify: + description: > + Whether to run the verification jobs — package validation, the sample builds, the + Release link checks and the e2e suites. Pull requests leave it at true, and are the only + place any of this runs. Releases pass false: the tagged commit was already verified on + its pull request, so a tag packs and publishes and nothing more. The gate is the same + input, with the same name and the same meaning, in every repository. + required: false + default: true + type: boolean version: description: NuGet version to stamp on every package. required: true @@ -33,25 +43,6 @@ on: required: false default: 35 type: number - build-sample: - description: > - Whether to compile the MAUI sample against the packed packages. Defaults to true, because - it is a verification step and a new caller should get it. Releases turn it off: by then - the same commit has already been through it on the pull request, and it would otherwise - put a MAUI workload install between the packages being built and being published. - required: false - default: true - type: boolean - verify-release: - description: > - Whether to run the Release-configuration jobs — the ones that prove the façade's types - survive the managed linker and that the platform bindings underneath still link for a - real device. Defaults to true, so a pull request gets them; releases turn them off, - because the tagged commit has already been through them on its own PR. - required: false - default: true - type: boolean - env: DOTNET_NOLOGO: 'true' DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' @@ -116,6 +107,7 @@ jobs: # against src/, the .sln, the version pins) and need nothing built; the package tests read the # .nupkg files just produced. Both run on a plain host with no workload. - name: Validate packages + if: ${{ inputs.verify }} env: OPENTOK_PACKAGE_VERSION: ${{ inputs.version }} run: | @@ -133,7 +125,7 @@ jobs: retention-days: 7 - name: Upload test results - if: always() + if: ${{ inputs.verify && (always()) }} uses: actions/upload-artifact@v4 with: name: package-test-results @@ -143,6 +135,7 @@ jobs: e2e-ios: name: simulator checks (${{ matrix.target-framework }}) + if: ${{ inputs.verify }} timeout-minutes: 45 needs: pack runs-on: macos-15 @@ -195,6 +188,7 @@ jobs: e2e-android: name: emulator checks (${{ matrix.target-framework }}) + if: ${{ inputs.verify }} timeout-minutes: 60 needs: pack strategy: @@ -330,7 +324,7 @@ jobs: name: build sample app timeout-minutes: 45 needs: pack - if: ${{ inputs.build-sample }} + if: ${{ inputs.verify }} runs-on: macos-15 steps: - uses: actions/checkout@v4 @@ -413,7 +407,7 @@ jobs: # of dependencies. The cost is that the iOS and Android legs wait for a Windows pack they do not # use; the alternative is a second nearly identical job. needs: [pack, add-windows-assets] - if: ${{ inputs.verify-release }} + if: ${{ inputs.verify }} runs-on: ${{ matrix.runs-on }} strategy: # fail-fast off: one platform failing and the other passing is a very different diagnosis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba10a6a..abed06b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,11 @@ name: release on: + # Dispatched as well as pushed to: auto-release.yml creates the tag with GITHUB_TOKEN when a + # release note is merged, and a tag pushed with that token deliberately does not trigger + # `on: push: tags`. workflow_dispatch is documented as an exception that always creates a run. + # Dispatched at the tag's ref, so github.ref_name below is the tag either way. + workflow_dispatch: push: tags: ['v*'] @@ -12,6 +17,37 @@ permissions: contents: read jobs: + # The release path packs and publishes without re-running validate/sample/e2e, on the grounds + # that the tagged commit already went through them on its pull request. That reasoning only + # holds if the commit is genuinely on the default branch — a tag cut from an unmerged branch, + # or from a commit force-pushed away since, would ship having been verified by nothing. Two + # cheap ubuntu minutes to make the assumption explicit rather than implicit. + guard: + name: verify the tag is on the default branch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Refuse a tag that never went through a pull request + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # A tag push checks out the tag, and the default branch's ref is not necessarily among + # the refs fetched with it, so ask for it by name before testing ancestry. + git fetch --no-tags --quiet origin \ + "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + + if ! git merge-base --is-ancestor "${GITHUB_SHA}" "origin/${DEFAULT_BRANCH}"; then + echo "::error::${GITHUB_REF_NAME} points at ${GITHUB_SHA}, which is not an ancestor of ${DEFAULT_BRANCH}. Releases skip the test suites because the tagged commit was verified on its pull request; this commit was not. Merge it first, then re-tag." + exit 1 + fi + + echo "${GITHUB_REF_NAME} -> ${GITHUB_SHA} is on ${DEFAULT_BRANCH}" >> "$GITHUB_STEP_SUMMARY" + version: name: resolve release version runs-on: ubuntu-latest @@ -66,17 +102,13 @@ jobs: build: name: build - needs: version + needs: [guard, version] uses: ./.github/workflows/build.yml with: version: ${{ needs.version.outputs.version }} - # The sample is a compile check on the public API, and the commit being tagged has already - # been through it on its pull request. Building it again here would put a MAUI workload - # install between the packages being built and being published, for a result already known. - build-sample: false - # Same reasoning: the Release jobs are a correctness check on the commit, which its own pull - # request already ran. - verify-release: false + # Verification already happened on this commit's pull request, and the guard job above + # proved the tag points at that commit. A release packs and publishes, nothing more. + verify: false publish: name: publish to nuget.org and create release